From ef8ebd6f009356736d09d285e174e9655dea5d95 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 09:43:47 +0200 Subject: [PATCH 01/39] refactor: Optimize server connectivity with Clean Architecture - Replace ServerConnectivity with CheckServerReachabilityUseCase - Add InfoApiClient for /api/info endpoint - Implement ServerInfoRepository with 30s cache TTL and 5s rate limiting - Update ShareBookmarkViewModel to use ShareExtensionServerCheck manager - Add server reachability check in AppViewModel on app start - Update OfflineSyncManager to use new UseCase - Extend SimpleAPI with checkServerReachability for Share Extension --- URLShare/ServerConnectivity.swift | 62 ---------- URLShare/ShareBookmarkViewModel.swift | 74 ++++++------ URLShare/ShareExtensionServerCheck.swift | 41 +++++++ URLShare/SimpleAPI.swift | 35 +++++- URLShare/SimpleAPIDTOs.swift | 13 +- readeck/Data/API/DTOs/ServerInfoDto.swift | 13 ++ readeck/Data/API/InfoApiClient.swift | 55 +++++++++ .../Data/Repository/OfflineSyncManager.swift | 13 +- .../Repository/ServerInfoRepository.swift | 114 ++++++++++++++++++ readeck/Domain/Model/ServerInfo.swift | 21 ++++ .../Protocols/PServerInfoRepository.swift | 10 ++ .../CheckServerReachabilityUseCase.swift | 28 +++++ readeck/UI/AppViewModel.swift | 20 ++- .../UI/Factory/DefaultUseCaseFactory.swift | 11 +- .../UI/Menu/OfflineBookmarksViewModel.swift | 4 +- readeck/UI/Menu/PhoneTabView.swift | 6 +- readeck/UI/readeckApp.swift | 2 - 17 files changed, 405 insertions(+), 117 deletions(-) delete mode 100644 URLShare/ServerConnectivity.swift create mode 100644 URLShare/ShareExtensionServerCheck.swift create mode 100644 readeck/Data/API/DTOs/ServerInfoDto.swift create mode 100644 readeck/Data/API/InfoApiClient.swift create mode 100644 readeck/Data/Repository/ServerInfoRepository.swift create mode 100644 readeck/Domain/Model/ServerInfo.swift create mode 100644 readeck/Domain/Protocols/PServerInfoRepository.swift create mode 100644 readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift diff --git a/URLShare/ServerConnectivity.swift b/URLShare/ServerConnectivity.swift deleted file mode 100644 index dd104f0..0000000 --- a/URLShare/ServerConnectivity.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import Network - -class ServerConnectivity: ObservableObject { - @Published var isServerReachable = false - - static let shared = ServerConnectivity() - - private init() {} - - // Check if the Readeck server endpoint is reachable - static func isServerReachable() async -> Bool { - guard let endpoint = KeychainHelper.shared.loadEndpoint(), - !endpoint.isEmpty, - let url = URL(string: endpoint + "/api/health") else { - return false - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.timeoutInterval = 5.0 // 5 second timeout - - do { - let (_, response) = try await URLSession.shared.data(for: request) - if let httpResponse = response as? HTTPURLResponse { - return httpResponse.statusCode == 200 - } - } catch { - print("Server connectivity check failed: \(error)") - } - - return false - } - - // Alternative check using ping-style endpoint - static func isServerReachableSync() -> Bool { - guard let endpoint = KeychainHelper.shared.loadEndpoint(), - !endpoint.isEmpty, - let url = URL(string: endpoint) else { - return false - } - - let semaphore = DispatchSemaphore(value: 0) - var isReachable = false - - var request = URLRequest(url: url) - request.httpMethod = "HEAD" // Just check if server responds - request.timeoutInterval = 3.0 - - let task = URLSession.shared.dataTask(with: request) { _, response, error in - if let httpResponse = response as? HTTPURLResponse { - isReachable = httpResponse.statusCode < 500 // Accept any response that's not server error - } - semaphore.signal() - } - - task.resume() - _ = semaphore.wait(timeout: .now() + 3.0) - - return isReachable - } -} \ No newline at end of file diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index ad22b9e..2e65612 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -13,8 +13,9 @@ class ShareBookmarkViewModel: ObservableObject { @Published var searchText: String = "" @Published var isServerReachable: Bool = true let extensionContext: NSExtensionContext? - + private let logger = Logger.viewModel + private let serverCheck = ShareExtensionServerCheck.shared var availableLabels: [BookmarkLabelDto] { return labels.filter { !selectedLabels.contains($0.name) } @@ -56,9 +57,14 @@ class ShareBookmarkViewModel: ObservableObject { private func checkServerReachability() { let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger) - isServerReachable = ServerConnectivity.isServerReachableSync() - logger.info("Server reachability checked: \(isServerReachable)") - measurement.end() + Task { + let reachable = await serverCheck.checkServerReachability() + await MainActor.run { + self.isServerReachable = reachable + logger.info("Server reachability checked: \(reachable)") + measurement.end() + } + } } private func extractSharedContent() { @@ -131,9 +137,9 @@ class ShareBookmarkViewModel: ObservableObject { let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger) logger.debug("Starting to load labels") Task { - let serverReachable = ServerConnectivity.isServerReachableSync() + let serverReachable = await serverCheck.checkServerReachability() logger.debug("Server reachable for labels: \(serverReachable)") - + if serverReachable { let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in self?.statusMessage = (message, error, error ? "❌" : "βœ…") @@ -168,14 +174,14 @@ class ShareBookmarkViewModel: ObservableObject { } isSaving = true logger.debug("Set saving state to true") - + // Check server connectivity - let serverReachable = ServerConnectivity.isServerReachableSync() - logger.debug("Server connectivity for save: \(serverReachable)") - if serverReachable { - // Online - try to save via API - logger.info("Attempting to save bookmark via API") - Task { + Task { + let serverReachable = await serverCheck.checkServerReachability() + logger.debug("Server connectivity for save: \(serverReachable)") + if serverReachable { + // Online - try to save via API + logger.info("Attempting to save bookmark via API") await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in self?.logger.info("API save completed - Success: \(!error), Message: \(message)") self?.statusMessage = (message, error, error ? "❌" : "βœ…") @@ -189,28 +195,28 @@ class ShareBookmarkViewModel: ObservableObject { self?.logger.error("Failed to save bookmark via API: \(message)") } } - } - } else { - // Server not reachable - save locally - logger.info("Server not reachable, attempting local save") - let success = OfflineBookmarkManager.shared.saveOfflineBookmark( - url: url, - title: title, - tags: Array(selectedLabels) - ) - logger.info("Local save result: \(success)") - - DispatchQueue.main.async { - self.isSaving = false - if success { - self.logger.info("Bookmark saved locally successfully") - self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.completeExtensionRequest() + } else { + // Server not reachable - save locally + logger.info("Server not reachable, attempting local save") + let success = OfflineBookmarkManager.shared.saveOfflineBookmark( + url: url, + title: title, + tags: Array(selectedLabels) + ) + logger.info("Local save result: \(success)") + + DispatchQueue.main.async { + self.isSaving = false + if success { + self.logger.info("Bookmark saved locally successfully") + self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.completeExtensionRequest() + } + } else { + self.logger.error("Failed to save bookmark locally") + self.statusMessage = ("Failed to save locally.", true, "❌") } - } else { - self.logger.error("Failed to save bookmark locally") - self.statusMessage = ("Failed to save locally.", true, "❌") } } } diff --git a/URLShare/ShareExtensionServerCheck.swift b/URLShare/ShareExtensionServerCheck.swift new file mode 100644 index 0000000..b3b2d7f --- /dev/null +++ b/URLShare/ShareExtensionServerCheck.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Simple server check manager for Share Extension with caching +class ShareExtensionServerCheck { + static let shared = ShareExtensionServerCheck() + + // Cache properties + private var cachedResult: Bool? + private var lastCheckTime: Date? + private let cacheTTL: TimeInterval = 30.0 + + private init() {} + + func checkServerReachability() async -> Bool { + // Check cache first + if let cached = getCachedResult() { + return cached + } + + // Use SimpleAPI for actual check + let result = await SimpleAPI.checkServerReachability() + updateCache(result: result) + return result + } + + // MARK: - Cache Management + + private func getCachedResult() -> Bool? { + guard let lastCheck = lastCheckTime, + Date().timeIntervalSince(lastCheck) < cacheTTL, + let cached = cachedResult else { + return nil + } + return cached + } + + private func updateCache(result: Bool) { + cachedResult = result + lastCheckTime = Date() + } +} diff --git a/URLShare/SimpleAPI.swift b/URLShare/SimpleAPI.swift index 5e4564e..6bdd019 100644 --- a/URLShare/SimpleAPI.swift +++ b/URLShare/SimpleAPI.swift @@ -2,7 +2,40 @@ import Foundation class SimpleAPI { private static let logger = Logger.network - + + // MARK: - Server Info + + static func checkServerReachability() async -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: "\(endpoint)/api/info") else { + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "accept") + request.timeoutInterval = 5.0 + + if let token = KeychainHelper.shared.loadToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization") + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse, + 200...299 ~= httpResponse.statusCode { + logger.info("Server is reachable") + return true + } + } catch { + logger.error("Server reachability check failed: \(error.localizedDescription)") + return false + } + + return false + } + // MARK: - API Methods static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async { logger.info("Adding bookmark: \(url)") diff --git a/URLShare/SimpleAPIDTOs.swift b/URLShare/SimpleAPIDTOs.swift index 65e2d06..7642f34 100644 --- a/URLShare/SimpleAPIDTOs.swift +++ b/URLShare/SimpleAPIDTOs.swift @@ -1,5 +1,17 @@ import Foundation +public struct ServerInfoDto: Codable { + public let version: String + public let buildDate: String? + public let userAgent: String? + + public enum CodingKeys: String, CodingKey { + case version + case buildDate = "build_date" + case userAgent = "user_agent" + } +} + public struct CreateBookmarkRequestDto: Codable { public let labels: [String]? public let title: String? @@ -33,4 +45,3 @@ public struct BookmarkLabelDto: Codable, Identifiable { self.href = href } } - diff --git a/readeck/Data/API/DTOs/ServerInfoDto.swift b/readeck/Data/API/DTOs/ServerInfoDto.swift new file mode 100644 index 0000000..f28ec77 --- /dev/null +++ b/readeck/Data/API/DTOs/ServerInfoDto.swift @@ -0,0 +1,13 @@ +import Foundation + +struct ServerInfoDto: Codable { + let version: String + let buildDate: String? + let userAgent: String? + + enum CodingKeys: String, CodingKey { + case version + case buildDate = "build_date" + case userAgent = "user_agent" + } +} diff --git a/readeck/Data/API/InfoApiClient.swift b/readeck/Data/API/InfoApiClient.swift new file mode 100644 index 0000000..e4ff7fa --- /dev/null +++ b/readeck/Data/API/InfoApiClient.swift @@ -0,0 +1,55 @@ +// +// InfoApiClient.swift +// readeck +// +// Created by Claude Code + +import Foundation + +protocol PInfoApiClient { + func getServerInfo() async throws -> ServerInfoDto +} + +class InfoApiClient: PInfoApiClient { + private let tokenProvider: TokenProvider + private let logger = Logger.network + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.tokenProvider = tokenProvider + } + + func getServerInfo() async throws -> ServerInfoDto { + guard let endpoint = await tokenProvider.getEndpoint(), + let url = URL(string: "\(endpoint)/api/info") else { + logger.error("Invalid endpoint URL for server info") + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "accept") + request.timeoutInterval = 5.0 + + if let token = await tokenProvider.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization") + } + + logger.logNetworkRequest(method: "GET", url: url.absoluteString) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid HTTP response for server info") + throw APIError.invalidResponse + } + + guard 200...299 ~= httpResponse.statusCode else { + logger.logNetworkError(method: "GET", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) + throw APIError.serverError(httpResponse.statusCode) + } + + logger.logNetworkRequest(method: "GET", url: url.absoluteString, statusCode: httpResponse.statusCode) + + return try JSONDecoder().decode(ServerInfoDto.self, from: data) + } +} diff --git a/readeck/Data/Repository/OfflineSyncManager.swift b/readeck/Data/Repository/OfflineSyncManager.swift index 2cdb0dd..609c4db 100644 --- a/readeck/Data/Repository/OfflineSyncManager.swift +++ b/readeck/Data/Repository/OfflineSyncManager.swift @@ -4,22 +4,25 @@ import SwiftUI class OfflineSyncManager: ObservableObject, @unchecked Sendable { static let shared = OfflineSyncManager() - + @Published var isSyncing = false @Published var syncStatus: String? - + private let coreDataManager = CoreDataManager.shared private let api: PAPI - - init(api: PAPI = API()) { + private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase + + init(api: PAPI = API(), + checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) { self.api = api + self.checkServerReachabilityUseCase = checkServerReachabilityUseCase } // MARK: - Sync Methods func syncOfflineBookmarks() async { // First check if server is reachable - guard await ServerConnectivity.isServerReachable() else { + guard await checkServerReachabilityUseCase.execute() else { await MainActor.run { isSyncing = false syncStatus = "Server not reachable. Cannot sync." diff --git a/readeck/Data/Repository/ServerInfoRepository.swift b/readeck/Data/Repository/ServerInfoRepository.swift new file mode 100644 index 0000000..0fe144d --- /dev/null +++ b/readeck/Data/Repository/ServerInfoRepository.swift @@ -0,0 +1,114 @@ +// +// ServerInfoRepository.swift +// readeck +// +// Created by Claude Code + +import Foundation + +class ServerInfoRepository: PServerInfoRepository { + private let apiClient: PInfoApiClient + private let logger = Logger.network + + // Cache properties + private var cachedServerInfo: ServerInfo? + private var lastCheckTime: Date? + private let cacheTTL: TimeInterval = 30.0 // 30 seconds cache + private let rateLimitInterval: TimeInterval = 5.0 // min 5 seconds between requests + + // Thread safety + private let queue = DispatchQueue(label: "com.readeck.serverInfoRepository", attributes: .concurrent) + + init(apiClient: PInfoApiClient) { + self.apiClient = apiClient + } + + func checkServerReachability() async -> Bool { + // Check cache first + if let cached = getCachedReachability() { + logger.debug("Server reachability from cache: \(cached)") + return cached + } + + // Check rate limiting + if isRateLimited() { + logger.debug("Server reachability check rate limited, using cached value") + return cachedServerInfo?.isReachable ?? false + } + + // Perform actual check + do { + let info = try await apiClient.getServerInfo() + let serverInfo = ServerInfo(from: info) + updateCache(serverInfo: serverInfo) + logger.info("Server reachability checked: true (version: \(info.version))") + return true + } catch { + let unreachableInfo = ServerInfo.unreachable + updateCache(serverInfo: unreachableInfo) + logger.warning("Server reachability check failed: \(error.localizedDescription)") + return false + } + } + + func getServerInfo() async throws -> ServerInfo { + // Check cache first + if let cached = getCachedServerInfo() { + logger.debug("Server info from cache") + return cached + } + + // Check rate limiting + if isRateLimited(), let cached = cachedServerInfo { + logger.debug("Server info check rate limited, using cached value") + return cached + } + + // Fetch fresh info + let dto = try await apiClient.getServerInfo() + let serverInfo = ServerInfo(from: dto) + updateCache(serverInfo: serverInfo) + logger.info("Server info fetched: version \(dto.version)") + return serverInfo + } + + // MARK: - Cache Management + + private func getCachedReachability() -> Bool? { + queue.sync { + guard let lastCheck = lastCheckTime, + Date().timeIntervalSince(lastCheck) < cacheTTL, + let cached = cachedServerInfo else { + return nil + } + return cached.isReachable + } + } + + private func getCachedServerInfo() -> ServerInfo? { + queue.sync { + guard let lastCheck = lastCheckTime, + Date().timeIntervalSince(lastCheck) < cacheTTL, + let cached = cachedServerInfo else { + return nil + } + return cached + } + } + + private func isRateLimited() -> Bool { + queue.sync { + guard let lastCheck = lastCheckTime else { + return false + } + return Date().timeIntervalSince(lastCheck) < rateLimitInterval + } + } + + private func updateCache(serverInfo: ServerInfo) { + queue.async(flags: .barrier) { [weak self] in + self?.cachedServerInfo = serverInfo + self?.lastCheckTime = Date() + } + } +} diff --git a/readeck/Domain/Model/ServerInfo.swift b/readeck/Domain/Model/ServerInfo.swift new file mode 100644 index 0000000..0a77f70 --- /dev/null +++ b/readeck/Domain/Model/ServerInfo.swift @@ -0,0 +1,21 @@ +import Foundation + +struct ServerInfo { + let version: String + let buildDate: String? + let userAgent: String? + let isReachable: Bool +} + +extension ServerInfo { + init(from dto: ServerInfoDto) { + self.version = dto.version + self.buildDate = dto.buildDate + self.userAgent = dto.userAgent + self.isReachable = true + } + + static var unreachable: ServerInfo { + ServerInfo(version: "", buildDate: nil, userAgent: nil, isReachable: false) + } +} diff --git a/readeck/Domain/Protocols/PServerInfoRepository.swift b/readeck/Domain/Protocols/PServerInfoRepository.swift new file mode 100644 index 0000000..9d628f2 --- /dev/null +++ b/readeck/Domain/Protocols/PServerInfoRepository.swift @@ -0,0 +1,10 @@ +// +// PServerInfoRepository.swift +// readeck +// +// Created by Claude Code + +protocol PServerInfoRepository { + func checkServerReachability() async -> Bool + func getServerInfo() async throws -> ServerInfo +} diff --git a/readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift b/readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift new file mode 100644 index 0000000..5248782 --- /dev/null +++ b/readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift @@ -0,0 +1,28 @@ +// +// CheckServerReachabilityUseCase.swift +// readeck +// +// Created by Claude Code + +import Foundation + +protocol PCheckServerReachabilityUseCase { + func execute() async -> Bool + func getServerInfo() async throws -> ServerInfo +} + +class CheckServerReachabilityUseCase: PCheckServerReachabilityUseCase { + private let repository: PServerInfoRepository + + init(repository: PServerInfoRepository) { + self.repository = repository + } + + func execute() async -> Bool { + return await repository.checkServerReachability() + } + + func getServerInfo() async throws -> ServerInfo { + return try await repository.getServerInfo() + } +} diff --git a/readeck/UI/AppViewModel.swift b/readeck/UI/AppViewModel.swift index e57ea7d..4e98406 100644 --- a/readeck/UI/AppViewModel.swift +++ b/readeck/UI/AppViewModel.swift @@ -11,15 +11,20 @@ import SwiftUI class AppViewModel: ObservableObject { private let settingsRepository = SettingsRepository() private let logoutUseCase: LogoutUseCase - + private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase + @Published var hasFinishedSetup: Bool = true - - init(logoutUseCase: LogoutUseCase = LogoutUseCase()) { + @Published var isServerReachable: Bool = false + + init(logoutUseCase: LogoutUseCase = LogoutUseCase(), + checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) { self.logoutUseCase = logoutUseCase + self.checkServerReachabilityUseCase = checkServerReachabilityUseCase setupNotificationObservers() - + Task { await loadSetupStatus() + await checkServerReachability() } } @@ -64,7 +69,12 @@ class AppViewModel: ObservableObject { private func loadSetupStatus() { hasFinishedSetup = settingsRepository.hasFinishedSetup } - + + @MainActor + private func checkServerReachability() async { + isServerReachable = await checkServerReachabilityUseCase.execute() + } + deinit { NotificationCenter.default.removeObserver(self) } diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index c107df5..bef448c 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -20,6 +20,7 @@ protocol UseCaseFactory { func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase + func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase } @@ -30,9 +31,11 @@ class DefaultUseCaseFactory: UseCaseFactory { private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository) private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api) private let settingsRepository: PSettingsRepository = SettingsRepository() - + private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider) + private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient) + static let shared = DefaultUseCaseFactory() - + private init() {} func makeLoginUseCase() -> PLoginUseCase { @@ -112,4 +115,8 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase { return SaveCardLayoutUseCase(settingsRepository: settingsRepository) } + + func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase { + return CheckServerReachabilityUseCase(repository: serverInfoRepository) + } } diff --git a/readeck/UI/Menu/OfflineBookmarksViewModel.swift b/readeck/UI/Menu/OfflineBookmarksViewModel.swift index 183d863..db416f2 100644 --- a/readeck/UI/Menu/OfflineBookmarksViewModel.swift +++ b/readeck/UI/Menu/OfflineBookmarksViewModel.swift @@ -11,8 +11,8 @@ class OfflineBookmarksViewModel { private let successDelaySubject = PassthroughSubject() private var completionTimerActive = false - init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) { - self.syncUseCase = syncUseCase + init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase() setupBindings() refreshState() } diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index 7828efb..dbd2f0d 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -12,7 +12,7 @@ struct PhoneTabView: View { private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings] @State private var selectedTab: SidebarTab = .unread - @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase()) + @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel() // Navigation paths for each tab @State private var allPath = NavigationPath() @@ -149,9 +149,9 @@ struct PhoneTabView: View { .padding() } else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty { List(bookmarks) { bookmark in - // Hidden NavigationLink to remove disclosure indicator - // To restore: uncomment block below and remove ZStack ZStack { + + // Hidden NavigationLink to remove disclosure indicator NavigationLink { BookmarkDetailView(bookmarkId: bookmark.id) } label: { diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index ef8bb35..e6fce1b 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -29,8 +29,6 @@ struct readeckApp: App { #if DEBUG NFX.sharedInstance().start() #endif - // Initialize server connectivity monitoring - _ = ServerConnectivity.shared Task { await loadAppSettings() } From bf3ee7a1d71629400697382da7b78ff9595f91bb Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 10:32:44 +0200 Subject: [PATCH 02/39] fix: Add MockCheckServerReachabilityUseCase implementation --- URLShare/SimpleAPI.swift | 2 +- readeck/UI/Factory/MockUseCaseFactory.swift | 14 ++++++++++++++ readeck/UI/Menu/OfflineBookmarksViewModel.swift | 2 +- readeck/UI/Menu/PadSidebarView.swift | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/URLShare/SimpleAPI.swift b/URLShare/SimpleAPI.swift index 6bdd019..4d20450 100644 --- a/URLShare/SimpleAPI.swift +++ b/URLShare/SimpleAPI.swift @@ -22,7 +22,7 @@ class SimpleAPI { } do { - let (data, response) = try await URLSession.shared.data(for: request) + let (_, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse, 200...299 ~= httpResponse.statusCode { logger.info("Server is reachable") diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index 21dc142..61dba4a 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -9,6 +9,10 @@ import Foundation import Combine class MockUseCaseFactory: UseCaseFactory { + func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase { + MockCheckServerReachabilityUseCase() + } + func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase { MockOfflineBookmarkSyncUseCase() } @@ -224,6 +228,16 @@ class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase { } } +class MockCheckServerReachabilityUseCase: PCheckServerReachabilityUseCase { + func execute() async -> Bool { + return true + } + + func getServerInfo() async throws -> ServerInfo { + return ServerInfo(version: "1.0.0", buildDate: nil, userAgent: nil, isReachable: true) + } +} + extension Bookmark { static let mock: Bookmark = .init( id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil) diff --git a/readeck/UI/Menu/OfflineBookmarksViewModel.swift b/readeck/UI/Menu/OfflineBookmarksViewModel.swift index db416f2..35139e1 100644 --- a/readeck/UI/Menu/OfflineBookmarksViewModel.swift +++ b/readeck/UI/Menu/OfflineBookmarksViewModel.swift @@ -12,7 +12,7 @@ class OfflineBookmarksViewModel { private var completionTimerActive = false init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { - self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase() + self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase() setupBindings() refreshState() } diff --git a/readeck/UI/Menu/PadSidebarView.swift b/readeck/UI/Menu/PadSidebarView.swift index 4910aa5..a373b48 100644 --- a/readeck/UI/Menu/PadSidebarView.swift +++ b/readeck/UI/Menu/PadSidebarView.swift @@ -13,7 +13,7 @@ struct PadSidebarView: View { @State private var selectedTag: BookmarkLabel? @EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var appSettings: AppSettings - @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase()) + @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel() private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags] From 1957995a9e1dd4c71b6d12fb2a0dbcce67e54024 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 10:45:21 +0200 Subject: [PATCH 03/39] refactor: Update NetworkConnectivity to use CheckServerReachabilityUseCase --- readeck/Data/Utils/NetworkConnectivity.swift | 46 +------------------- 1 file changed, 2 insertions(+), 44 deletions(-) diff --git a/readeck/Data/Utils/NetworkConnectivity.swift b/readeck/Data/Utils/NetworkConnectivity.swift index 19e8aed..ef8e882 100644 --- a/readeck/Data/Utils/NetworkConnectivity.swift +++ b/readeck/Data/Utils/NetworkConnectivity.swift @@ -44,49 +44,7 @@ class ServerConnectivity: ObservableObject { // Check if the Readeck server endpoint is reachable static func isServerReachable() async -> Bool { - guard let endpoint = KeychainHelper.shared.loadEndpoint(), - !endpoint.isEmpty, - let url = URL(string: endpoint + "/api/health") else { - return false - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.timeoutInterval = 5.0 // 5 second timeout - - do { - let (_, response) = try await URLSession.shared.data(for: request) - if let httpResponse = response as? HTTPURLResponse { - return httpResponse.statusCode == 200 - } - } catch { - // Fallback: try basic endpoint if health endpoint doesn't exist - return await isBasicEndpointReachable() - } - - return false - } - - private static func isBasicEndpointReachable() async -> Bool { - guard let endpoint = KeychainHelper.shared.loadEndpoint(), - !endpoint.isEmpty, - let url = URL(string: endpoint) else { - return false - } - - var request = URLRequest(url: url) - request.httpMethod = "HEAD" - request.timeoutInterval = 3.0 - - do { - let (_, response) = try await URLSession.shared.data(for: request) - if let httpResponse = response as? HTTPURLResponse { - return httpResponse.statusCode < 500 - } - } catch { - print("Server connectivity check failed: \(error)") - } - - return false + let useCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase() + return await useCase.execute() } } From e5334d456ddadc2e040fcc73f7179f8fedaee8b6 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 10:47:19 +0200 Subject: [PATCH 04/39] refactor: Remove NWPathMonitor auto-sync, keep only on-demand server checks - Delete NetworkConnectivity.swift with problematic NWPathMonitor - Remove serverDidBecomeAvailable notification - Remove unused startAutoSync from OfflineSyncManager - Server check now only on app start via AppViewModel --- .../Data/Repository/OfflineSyncManager.swift | 18 ------- readeck/Data/Utils/NetworkConnectivity.swift | 50 ------------------- readeck/UI/Utils/NotificationNames.swift | 5 +- 3 files changed, 1 insertion(+), 72 deletions(-) delete mode 100644 readeck/Data/Utils/NetworkConnectivity.swift diff --git a/readeck/Data/Repository/OfflineSyncManager.swift b/readeck/Data/Repository/OfflineSyncManager.swift index 609c4db..3216677 100644 --- a/readeck/Data/Repository/OfflineSyncManager.swift +++ b/readeck/Data/Repository/OfflineSyncManager.swift @@ -124,22 +124,4 @@ class OfflineSyncManager: ObservableObject, @unchecked Sendable { } } - // MARK: - Auto Sync on Server Connectivity Changes - - func startAutoSync() { - // Monitor server connectivity and auto-sync when server becomes reachable - NotificationCenter.default.addObserver( - forName: .serverDidBecomeAvailable, - object: nil, - queue: .main - ) { [weak self] _ in - Task { - await self?.syncOfflineBookmarks() - } - } - } - - deinit { - NotificationCenter.default.removeObserver(self) - } } diff --git a/readeck/Data/Utils/NetworkConnectivity.swift b/readeck/Data/Utils/NetworkConnectivity.swift deleted file mode 100644 index ef8e882..0000000 --- a/readeck/Data/Utils/NetworkConnectivity.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -import Network - -class ServerConnectivity: ObservableObject { - private let monitor = NWPathMonitor() - private let queue = DispatchQueue.global(qos: .background) - - @Published var isServerReachable = false - - static let shared = ServerConnectivity() - - private init() { - startMonitoring() - } - - private func startMonitoring() { - monitor.pathUpdateHandler = { [weak self] path in - if path.status == .satisfied { - // Network is available, now check server - Task { - let serverReachable = await ServerConnectivity.isServerReachable() - DispatchQueue.main.async { - let wasReachable = self?.isServerReachable ?? false - self?.isServerReachable = serverReachable - - // Notify when server becomes available - if !wasReachable && serverReachable { - NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil) - } - } - } - } else { - DispatchQueue.main.async { - self?.isServerReachable = false - } - } - } - monitor.start(queue: queue) - } - - deinit { - monitor.cancel() - } - - // Check if the Readeck server endpoint is reachable - static func isServerReachable() async -> Bool { - let useCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase() - return await useCase.execute() - } -} diff --git a/readeck/UI/Utils/NotificationNames.swift b/readeck/UI/Utils/NotificationNames.swift index b1f692e..d3f7716 100644 --- a/readeck/UI/Utils/NotificationNames.swift +++ b/readeck/UI/Utils/NotificationNames.swift @@ -7,10 +7,7 @@ extension Notification.Name { // MARK: - Authentication static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse") - - // MARK: - Network - static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable") - + // MARK: - UI Interactions static let dismissKeyboard = Notification.Name("DismissKeyboard") static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare") From fde1140f2407de239b73838e18f8a66922767c68 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 11:08:13 +0200 Subject: [PATCH 05/39] refactor: Check server reachability on app resume instead of app start - Move server check from init to onAppResume() in AppViewModel - Add scenePhase observer in readeckApp - Check only when app becomes active (.active phase) - Respects 30s cache - won't call API if recently checked --- readeck/UI/AppViewModel.swift | 6 +++++- readeck/UI/readeckApp.swift | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/readeck/UI/AppViewModel.swift b/readeck/UI/AppViewModel.swift index 4e98406..182ef83 100644 --- a/readeck/UI/AppViewModel.swift +++ b/readeck/UI/AppViewModel.swift @@ -24,7 +24,6 @@ class AppViewModel: ObservableObject { Task { await loadSetupStatus() - await checkServerReachability() } } @@ -70,6 +69,11 @@ class AppViewModel: ObservableObject { hasFinishedSetup = settingsRepository.hasFinishedSetup } + @MainActor + func onAppResume() async { + await checkServerReachability() + } + @MainActor private func checkServerReachability() async { isServerReachable = await checkServerReachabilityUseCase.execute() diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index e6fce1b..07d0fdd 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -12,6 +12,7 @@ import netfox struct readeckApp: App { @StateObject private var appViewModel = AppViewModel() @StateObject private var appSettings = AppSettings() + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { @@ -38,6 +39,13 @@ struct readeckApp: App { await loadAppSettings() } } + .onChange(of: scenePhase) { oldPhase, newPhase in + if newPhase == .active { + Task { + await appViewModel.onAppResume() + } + } + } } } From 04de2c20d4483f2b9607ce22801f7115144f6876 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 19:01:54 +0200 Subject: [PATCH 06/39] refactor: Use @Observable and inject factory in AppViewModel - Replace ObservableObject with @Observable macro - Inject UseCaseFactory instead of individual use cases - Use factory.makeCheckServerReachabilityUseCase() on demand - Use factory.makeLogoutUseCase() for 401 handling --- readeck/UI/AppViewModel.swift | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/readeck/UI/AppViewModel.swift b/readeck/UI/AppViewModel.swift index 182ef83..f16aa1e 100644 --- a/readeck/UI/AppViewModel.swift +++ b/readeck/UI/AppViewModel.swift @@ -8,18 +8,17 @@ import Foundation import SwiftUI -class AppViewModel: ObservableObject { +@MainActor +@Observable +class AppViewModel { private let settingsRepository = SettingsRepository() - private let logoutUseCase: LogoutUseCase - private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase + private let factory: UseCaseFactory - @Published var hasFinishedSetup: Bool = true - @Published var isServerReachable: Bool = false + var hasFinishedSetup: Bool = true + var isServerReachable: Bool = false - init(logoutUseCase: LogoutUseCase = LogoutUseCase(), - checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) { - self.logoutUseCase = logoutUseCase - self.checkServerReachabilityUseCase = checkServerReachabilityUseCase + init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + self.factory = factory setupNotificationObservers() Task { @@ -33,7 +32,7 @@ class AppViewModel: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - Task { + Task { @MainActor in await self?.handleUnauthorizedResponse() } } @@ -43,19 +42,17 @@ class AppViewModel: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - self?.loadSetupStatus() + Task { @MainActor in + self?.loadSetupStatus() + } } } - @MainActor private func handleUnauthorizedResponse() async { print("AppViewModel: Handling 401 Unauthorized - logging out user") do { - // FΓΌhre den Logout durch - try await logoutUseCase.execute() - - // Update UI state + try await factory.makeLogoutUseCase().execute() loadSetupStatus() print("AppViewModel: User successfully logged out due to 401 error") @@ -64,19 +61,16 @@ class AppViewModel: ObservableObject { } } - @MainActor private func loadSetupStatus() { hasFinishedSetup = settingsRepository.hasFinishedSetup } - @MainActor func onAppResume() async { await checkServerReachability() } - @MainActor private func checkServerReachability() async { - isServerReachable = await checkServerReachabilityUseCase.execute() + isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute() } deinit { From 31ed3fc0e1759fe14faea07fa0845215cb74f390 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 19:06:35 +0200 Subject: [PATCH 07/39] fix: Use @State instead of @StateObject for @Observable AppViewModel - Replace @StateObject with @State for @Observable conformance - Remove unnecessary Task wrapper in init - Call loadSetupStatus() synchronously since it's already @MainActor --- readeck/UI/AppViewModel.swift | 4 +--- readeck/UI/readeckApp.swift | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/readeck/UI/AppViewModel.swift b/readeck/UI/AppViewModel.swift index f16aa1e..cf97bc9 100644 --- a/readeck/UI/AppViewModel.swift +++ b/readeck/UI/AppViewModel.swift @@ -21,9 +21,7 @@ class AppViewModel { self.factory = factory setupNotificationObservers() - Task { - await loadSetupStatus() - } + loadSetupStatus() } private func setupNotificationObservers() { diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 07d0fdd..1f50ad0 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -10,7 +10,7 @@ import netfox @main struct readeckApp: App { - @StateObject private var appViewModel = AppViewModel() + @State private var appViewModel = AppViewModel() @StateObject private var appSettings = AppSettings() @Environment(\.scenePhase) private var scenePhase From 6385d1031782a2cd9bd4c5cbf482220a12c07d82 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 19:16:12 +0200 Subject: [PATCH 08/39] fix: Set gray tint color for server endpoint TextField placeholder --- readeck/UI/Settings/SettingsServerView.swift | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift index 067dd86..9c3eb38 100644 --- a/readeck/UI/Settings/SettingsServerView.swift +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -11,8 +11,7 @@ struct SettingsServerView: View { @State private var viewModel = SettingsServerViewModel() @State private var showingLogoutAlert = false - init(viewModel: SettingsServerViewModel = SettingsServerViewModel(), showingLogoutAlert: Bool = false) { - self.viewModel = viewModel + init(showingLogoutAlert: Bool = false) { self.showingLogoutAlert = showingLogoutAlert } @@ -35,11 +34,12 @@ struct SettingsServerView: View { Text("Server Endpoint") .font(.headline) if viewModel.isSetupMode { - TextField("https://readeck.example.com", text: $viewModel.endpoint) + TextField("http://192.168.0.77:8000", text: $viewModel.endpoint) .textFieldStyle(.roundedBorder) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) + .tint(.gray) .onChange(of: viewModel.endpoint) { viewModel.clearMessages() } @@ -171,9 +171,3 @@ struct SettingsServerView: View { } } } - -#Preview { - SettingsServerView(viewModel: .init( - MockUseCaseFactory() - )) -} From 819eb4fc56e78bfc75423154349b6a246dd79b68 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 19:17:30 +0200 Subject: [PATCH 09/39] feat: Add helpful hint text for server endpoint field - Clarify HTTP/HTTPS support - Note HTTP restriction to local networks - Mention optional port configuration - Indicate trailing slash not required --- readeck/UI/Settings/SettingsServerView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift index 9c3eb38..ae26541 100644 --- a/readeck/UI/Settings/SettingsServerView.swift +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -43,6 +43,11 @@ struct SettingsServerView: View { .onChange(of: viewModel.endpoint) { viewModel.clearMessages() } + + Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } else { HStack { Image(systemName: "server.rack") From 554e223bbc4aa1b32095cbcab0f792c5d66ddd1a Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 19:26:40 +0200 Subject: [PATCH 10/39] feat: Redesign server settings form with prompt parameters and quick input chips - Remove redundant field labels, use prompt parameter instead - Add QuickInputChip component for quick URL entry - Add chips: http://, https://, 192.168., :8000 - Improve spacing and layout consistency - Cleaner, more modern UI appearance --- readeck/UI/Settings/SettingsServerView.swift | 98 +++++++++++++++----- 1 file changed, 76 insertions(+), 22 deletions(-) diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift index ae26541..618ee6a 100644 --- a/readeck/UI/Settings/SettingsServerView.swift +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -10,16 +10,16 @@ import SwiftUI struct SettingsServerView: View { @State private var viewModel = SettingsServerViewModel() @State private var showingLogoutAlert = false - + init(showingLogoutAlert: Bool = false) { self.showingLogoutAlert = showingLogoutAlert } - + var body: some View { VStack(spacing: 20) { SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack") .padding(.bottom, 4) - + Text(viewModel.isSetupMode ? "Enter your Readeck server details to get started." : "Your current server connection and login credentials.") @@ -27,23 +27,54 @@ struct SettingsServerView: View { .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.bottom, 8) - + // Form VStack(spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Text("Server Endpoint") - .font(.headline) + // Server Endpoint + VStack(alignment: .leading, spacing: 8) { if viewModel.isSetupMode { - TextField("http://192.168.0.77:8000", text: $viewModel.endpoint) + TextField("", + text: $viewModel.endpoint, + prompt: Text("Server Endpoint").foregroundColor(.secondary)) .textFieldStyle(.roundedBorder) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) - .tint(.gray) .onChange(of: viewModel.endpoint) { viewModel.clearMessages() } + // Quick Input Chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + QuickInputChip(text: "http://", action: { + if !viewModel.endpoint.starts(with: "http") { + viewModel.endpoint = "http://" + viewModel.endpoint + } + }) + QuickInputChip(text: "https://", action: { + if !viewModel.endpoint.starts(with: "http") { + viewModel.endpoint = "https://" + viewModel.endpoint + } + }) + QuickInputChip(text: "192.168.", action: { + if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" { + if viewModel.endpoint.starts(with: "http") { + viewModel.endpoint += "192.168." + } else { + viewModel.endpoint = "http://192.168." + } + } + }) + QuickInputChip(text: ":8000", action: { + if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") { + viewModel.endpoint += ":8000" + } + }) + } + .padding(.horizontal, 1) + } + Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.") .font(.caption) .foregroundColor(.secondary) @@ -60,11 +91,13 @@ struct SettingsServerView: View { .padding(.vertical, 8) } } - VStack(alignment: .leading, spacing: 6) { - Text("Username") - .font(.headline) + + // Username + VStack(alignment: .leading, spacing: 8) { if viewModel.isSetupMode { - TextField("Your Username", text: $viewModel.username) + TextField("", + text: $viewModel.username, + prompt: Text("Username").foregroundColor(.secondary)) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) @@ -83,12 +116,13 @@ struct SettingsServerView: View { .padding(.vertical, 8) } } + + // Password if viewModel.isSetupMode { - VStack(alignment: .leading, spacing: 6) { - Text("Password") - .font(.headline) - - SecureField("Your Password", text: $viewModel.password) + VStack(alignment: .leading, spacing: 8) { + SecureField("", + text: $viewModel.password, + prompt: Text("Password").foregroundColor(.secondary)) .textFieldStyle(.roundedBorder) .onChange(of: viewModel.password) { viewModel.clearMessages() @@ -96,7 +130,7 @@ struct SettingsServerView: View { } } } - + // Messages if let errorMessage = viewModel.errorMessage { HStack { @@ -107,7 +141,7 @@ struct SettingsServerView: View { .font(.caption) } } - + if let successMessage = viewModel.successMessage { HStack { Image(systemName: "checkmark.circle.fill") @@ -117,7 +151,7 @@ struct SettingsServerView: View { .font(.caption) } } - + if viewModel.isSetupMode { VStack(spacing: 10) { Button(action: { @@ -140,7 +174,7 @@ struct SettingsServerView: View { .foregroundColor(.white) .cornerRadius(10) } - .disabled(!viewModel.canLogin || viewModel.isLoading) + .disabled(!viewModel.canLogin || viewModel.isLoading) } } else { Button(action: { @@ -176,3 +210,23 @@ struct SettingsServerView: View { } } } + +// MARK: - Quick Input Chip Component + +struct QuickInputChip: View { + let text: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(text) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(.systemGray5)) + .foregroundColor(.secondary) + .cornerRadius(12) + } + } +} From afe3d1e2612a3a9f64e264f33bd21f99ad038061 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 19:37:35 +0200 Subject: [PATCH 11/39] feat: Add endpoint normalization with validation rules - Default to https if no scheme provided - Only accept http and https schemes - Add trailing slash to path automatically - Remove query parameters and fragments - Update endpoint field with normalized value after save --- .../UI/Settings/SettingsServerViewModel.swift | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/readeck/UI/Settings/SettingsServerViewModel.swift b/readeck/UI/Settings/SettingsServerViewModel.swift index 30abf33..468757a 100644 --- a/readeck/UI/Settings/SettingsServerViewModel.swift +++ b/readeck/UI/Settings/SettingsServerViewModel.swift @@ -62,8 +62,15 @@ class SettingsServerViewModel { isLoading = true defer { isLoading = false } do { - let user = try await loginUseCase.execute(endpoint: endpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password) - try await saveServerSettingsUseCase.execute(endpoint: endpoint, username: username, password: password, token: user.token) + // Normalize endpoint before saving + let normalizedEndpoint = normalizeEndpoint(endpoint) + + let user = try await loginUseCase.execute(endpoint: normalizedEndpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password) + try await saveServerSettingsUseCase.execute(endpoint: normalizedEndpoint, username: username, password: password, token: user.token) + + // Update local endpoint with normalized version + endpoint = normalizedEndpoint + isLoggedIn = true successMessage = "Server settings saved and successfully logged in." try await SettingsRepository().saveHasFinishedSetup(true) @@ -73,6 +80,55 @@ class SettingsServerViewModel { isLoggedIn = false } } + + // MARK: - Endpoint Normalization + + private func normalizeEndpoint(_ endpoint: String) -> String { + var normalized = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + + // Remove query parameters + if let queryIndex = normalized.firstIndex(of: "?") { + normalized = String(normalized[.. String { + var urlComponents = components + + // Ensure scheme is http or https, default to https + if urlComponents.scheme == nil { + urlComponents.scheme = "https" + } else if urlComponents.scheme != "http" && urlComponents.scheme != "https" { + urlComponents.scheme = "https" + } + + // Add trailing slash to path if not present + if urlComponents.path.isEmpty || !urlComponents.path.hasSuffix("/") { + if urlComponents.path.isEmpty { + urlComponents.path = "/" + } else { + urlComponents.path += "/" + } + } + + // Remove query parameters (already done above, but double check) + urlComponents.query = nil + urlComponents.fragment = nil + + return urlComponents.string ?? components.string ?? "" + } @MainActor func logout() async { From 6906509aea4d7948bb0cef95da8db02fb5d481fd Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 19:40:25 +0200 Subject: [PATCH 12/39] fix: Remove trailing slash from endpoint instead of adding it Trailing slash is added elsewhere in the codebase, so here we remove it if present to avoid duplication --- readeck/UI/Settings/SettingsServerViewModel.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/readeck/UI/Settings/SettingsServerViewModel.swift b/readeck/UI/Settings/SettingsServerViewModel.swift index 468757a..5ac59df 100644 --- a/readeck/UI/Settings/SettingsServerViewModel.swift +++ b/readeck/UI/Settings/SettingsServerViewModel.swift @@ -114,13 +114,9 @@ class SettingsServerViewModel { urlComponents.scheme = "https" } - // Add trailing slash to path if not present - if urlComponents.path.isEmpty || !urlComponents.path.hasSuffix("/") { - if urlComponents.path.isEmpty { - urlComponents.path = "/" - } else { - urlComponents.path += "/" - } + // Remove trailing slash from path if present + if urlComponents.path.hasSuffix("/") { + urlComponents.path = String(urlComponents.path.dropLast()) } // Remove query parameters (already done above, but double check) From d97e404cc790a60a5a2b9294b073de91b9baea4b Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 20:40:02 +0200 Subject: [PATCH 13/39] fix: Prevent UICollectionView crash from concurrent bookmark list updates Add isUpdating flag to prevent race conditions when updating the bookmark list. This fixes crashes that occurred when returning to the app after adding a bookmark via the share extension while the list was being updated. --- readeck/UI/Bookmarks/BookmarksViewModel.swift | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/readeck/UI/Bookmarks/BookmarksViewModel.swift b/readeck/UI/Bookmarks/BookmarksViewModel.swift index b4e43e8..f2c569f 100644 --- a/readeck/UI/Bookmarks/BookmarksViewModel.swift +++ b/readeck/UI/Bookmarks/BookmarksViewModel.swift @@ -22,9 +22,12 @@ class BookmarksViewModel { var showingAddBookmarkFromShare = false var shareURL = "" var shareTitle = "" - + // Undo delete functionality var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete + + // Prevent concurrent updates + private var isUpdating = false private var cancellables = Set() @@ -104,15 +107,19 @@ class BookmarksViewModel { @MainActor func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async { + guard !isUpdating else { return } + isUpdating = true + defer { isUpdating = false } + isLoading = true errorMessage = nil currentState = state currentType = type currentTag = tag - + offset = 0 hasMoreData = true - + do { let newBookmarks = try await getBooksmarksUseCase.execute( state: state, @@ -142,18 +149,20 @@ class BookmarksViewModel { } // Don't clear bookmarks on error - keep existing data visible } - + isLoading = false isInitialLoading = false } @MainActor func loadMoreBookmarks() async { - guard !isLoading && hasMoreData else { return } // prevent multiple loads - + guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads + isUpdating = true + defer { isUpdating = false } + isLoading = true errorMessage = nil - + do { offset += limit // inc. offset let newBookmarks = try await getBooksmarksUseCase.execute( @@ -181,7 +190,7 @@ class BookmarksViewModel { errorMessage = "Error loading more bookmarks" } } - + isLoading = false } From 47f8f7366442afda47fd271789263f6618abb643 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 19 Oct 2025 20:41:04 +0200 Subject: [PATCH 14/39] fix: Improve markdown formatting in release notes view Add custom AttributedString extension to properly format markdown with correct spacing and header styles. This fixes the compressed appearance of release notes by adding proper line breaks between sections and applying appropriate font sizes to headers. --- readeck/UI/Settings/ReleaseNotesView.swift | 46 ++++++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/readeck/UI/Settings/ReleaseNotesView.swift b/readeck/UI/Settings/ReleaseNotesView.swift index a717155..76724af 100644 --- a/readeck/UI/Settings/ReleaseNotesView.swift +++ b/readeck/UI/Settings/ReleaseNotesView.swift @@ -1,5 +1,46 @@ import SwiftUI +extension AttributedString { + init(styledMarkdown markdownString: String) throws { + var output = try AttributedString( + markdown: markdownString, + options: .init( + allowsExtendedAttributes: true, + interpretedSyntax: .full, + failurePolicy: .returnPartiallyParsedIfPossible + ), + baseURL: nil + ) + + for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() { + guard let intentBlock = intentBlock else { continue } + for intent in intentBlock.components { + switch intent.kind { + case .header(level: let level): + switch level { + case 1: + output[intentRange].font = .system(.title).bold() + case 2: + output[intentRange].font = .system(.title2).bold() + case 3: + output[intentRange].font = .system(.title3).bold() + default: + break + } + default: + break + } + } + + if intentRange.lowerBound != output.startIndex { + output.characters.insert(contentsOf: "\n\n", at: intentRange.lowerBound) + } + } + + self = output + } +} + struct ReleaseNotesView: View { @Environment(\.dismiss) private var dismiss @@ -33,10 +74,7 @@ struct ReleaseNotesView: View { private func loadReleaseNotes() -> AttributedString? { guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"), let markdownContent = try? String(contentsOf: url), - let attributedString = try? AttributedString( - markdown: markdownContent, - options: .init(interpretedSyntax: .full) - ) else { + let attributedString = try? AttributedString(styledMarkdown: markdownContent) else { return nil } return attributedString From cf06a3147d9fac0992dbbc6eecb15e1729a3e88d Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 22 Oct 2025 15:25:55 +0200 Subject: [PATCH 15/39] feat: Add annotations support with color-coded highlighting Add comprehensive annotations feature to bookmark detail views: - Implement annotations list view with date formatting and state machine - Add CSS-based highlighting for rd-annotation tags in WebView components - Support Readeck color scheme (yellow, green, blue, red) for annotations - Enable tap-to-scroll functionality to navigate to selected annotations - Integrate annotations button in bookmark detail toolbar - Add API endpoint and repository layer for fetching annotations --- readeck/Data/API/API.swift | 19 ++- readeck/Data/API/DTOs/AnnotationDto.swift | 21 +++ .../Repository/AnnotationsRepository.swift | 24 ++++ readeck/Domain/Model/Annotation.swift | 19 +++ .../Protocols/PAnnotationsRepository.swift | 3 + .../GetBookmarkAnnotationsUseCase.swift | 17 +++ .../BookmarkDetail/AnnotationsListView.swift | 120 ++++++++++++++++++ .../AnnotationsListViewModel.swift | 29 +++++ .../BookmarkDetailLegacyView.swift | 32 ++++- .../BookmarkDetail/BookmarkDetailView2.swift | 25 +++- .../BookmarkDetailViewModel.swift | 13 +- readeck/UI/Components/NativeWebView.swift | 83 +++++++++++- readeck/UI/Components/WebView.swift | 78 ++++++++++++ .../UI/Factory/DefaultUseCaseFactory.swift | 6 + readeck/UI/Factory/MockUseCaseFactory.swift | 12 ++ readeck/UI/Menu/PadSidebarView.swift | 6 +- readeck/UI/Menu/PhoneTabView.swift | 8 +- 17 files changed, 494 insertions(+), 21 deletions(-) create mode 100644 readeck/Data/API/DTOs/AnnotationDto.swift create mode 100644 readeck/Data/Repository/AnnotationsRepository.swift create mode 100644 readeck/Domain/Model/Annotation.swift create mode 100644 readeck/Domain/Protocols/PAnnotationsRepository.swift create mode 100644 readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift create mode 100644 readeck/UI/BookmarkDetail/AnnotationsListView.swift create mode 100644 readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index 57cb0f1..901d247 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -18,6 +18,7 @@ protocol PAPI { func deleteBookmark(id: String) async throws func searchBookmarks(search: String) async throws -> BookmarksPageDto func getBookmarkLabels() async throws -> [BookmarkLabelDto] + func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] } class API: PAPI { @@ -435,15 +436,29 @@ class API: PAPI { logger.debug("Fetching bookmark labels") let endpoint = "/api/bookmarks/labels" logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint) - + let result = try await makeJSONRequest( endpoint: endpoint, responseType: [BookmarkLabelDto].self ) - + logger.info("Successfully fetched \(result.count) bookmark labels") return result } + + func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] { + logger.debug("Fetching annotations for bookmark: \(bookmarkId)") + let endpoint = "/api/bookmarks/\(bookmarkId)/annotations" + logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint) + + let result = try await makeJSONRequest( + endpoint: endpoint, + responseType: [AnnotationDto].self + ) + + logger.info("Successfully fetched \(result.count) annotations for bookmark: \(bookmarkId)") + return result + } } enum HTTPMethod: String { diff --git a/readeck/Data/API/DTOs/AnnotationDto.swift b/readeck/Data/API/DTOs/AnnotationDto.swift new file mode 100644 index 0000000..54bce0b --- /dev/null +++ b/readeck/Data/API/DTOs/AnnotationDto.swift @@ -0,0 +1,21 @@ +import Foundation + +struct AnnotationDto: Codable { + let id: String + let text: String + let created: String + let startOffset: Int + let endOffset: Int + let startSelector: String + let endSelector: String + + enum CodingKeys: String, CodingKey { + case id + case text + case created + case startOffset = "start_offset" + case endOffset = "end_offset" + case startSelector = "start_selector" + case endSelector = "end_selector" + } +} diff --git a/readeck/Data/Repository/AnnotationsRepository.swift b/readeck/Data/Repository/AnnotationsRepository.swift new file mode 100644 index 0000000..e4a7afc --- /dev/null +++ b/readeck/Data/Repository/AnnotationsRepository.swift @@ -0,0 +1,24 @@ +import Foundation + +class AnnotationsRepository: PAnnotationsRepository { + private let api: PAPI + + init(api: PAPI) { + self.api = api + } + + func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] { + let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId) + return annotationDtos.map { dto in + Annotation( + id: dto.id, + text: dto.text, + created: dto.created, + startOffset: dto.startOffset, + endOffset: dto.endOffset, + startSelector: dto.startSelector, + endSelector: dto.endSelector + ) + } + } +} diff --git a/readeck/Domain/Model/Annotation.swift b/readeck/Domain/Model/Annotation.swift new file mode 100644 index 0000000..4a5b9c6 --- /dev/null +++ b/readeck/Domain/Model/Annotation.swift @@ -0,0 +1,19 @@ +import Foundation + +struct Annotation: Identifiable, Hashable { + let id: String + let text: String + let created: String + let startOffset: Int + let endOffset: Int + let startSelector: String + let endSelector: String + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Annotation, rhs: Annotation) -> Bool { + lhs.id == rhs.id + } +} diff --git a/readeck/Domain/Protocols/PAnnotationsRepository.swift b/readeck/Domain/Protocols/PAnnotationsRepository.swift new file mode 100644 index 0000000..122b67c --- /dev/null +++ b/readeck/Domain/Protocols/PAnnotationsRepository.swift @@ -0,0 +1,3 @@ +protocol PAnnotationsRepository { + func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] +} diff --git a/readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift b/readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift new file mode 100644 index 0000000..7e9db77 --- /dev/null +++ b/readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol PGetBookmarkAnnotationsUseCase { + func execute(bookmarkId: String) async throws -> [Annotation] +} + +class GetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase { + private let repository: PAnnotationsRepository + + init(repository: PAnnotationsRepository) { + self.repository = repository + } + + func execute(bookmarkId: String) async throws -> [Annotation] { + return try await repository.fetchAnnotations(bookmarkId: bookmarkId) + } +} diff --git a/readeck/UI/BookmarkDetail/AnnotationsListView.swift b/readeck/UI/BookmarkDetail/AnnotationsListView.swift new file mode 100644 index 0000000..4c63668 --- /dev/null +++ b/readeck/UI/BookmarkDetail/AnnotationsListView.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct AnnotationsListView: View { + let bookmarkId: String + @State private var viewModel = AnnotationsListViewModel() + @Environment(\.dismiss) private var dismiss + var onAnnotationTap: ((String) -> Void)? + + enum ViewState { + case loading + case empty + case loaded([Annotation]) + case error(String) + } + + private var viewState: ViewState { + if viewModel.isLoading { + return .loading + } else if let error = viewModel.errorMessage, viewModel.showErrorAlert { + return .error(error) + } else if viewModel.annotations.isEmpty { + return .empty + } else { + return .loaded(viewModel.annotations) + } + } + + var body: some View { + List { + switch viewState { + case .loading: + HStack { + Spacer() + ProgressView() + Spacer() + } + + case .empty: + ContentUnavailableView( + "No Annotations", + systemImage: "pencil.slash", + description: Text("This bookmark has no annotations yet.") + ) + + case .loaded(let annotations): + ForEach(annotations) { annotation in + Button(action: { + onAnnotationTap?(annotation.id) + dismiss() + }) { + VStack(alignment: .leading, spacing: 8) { + if !annotation.text.isEmpty { + Text(annotation.text) + .font(.body) + .foregroundColor(.primary) + } + + Text(formatDate(annotation.created)) + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + case .error: + EmptyView() + } + } + .navigationTitle("Annotations") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + .task { + await viewModel.loadAnnotations(for: bookmarkId) + } + .alert("Error", isPresented: $viewModel.showErrorAlert) { + Button("OK", role: .cancel) {} + } message: { + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + } + } + } + + private func formatDate(_ dateString: String) -> String { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoFormatterNoMillis = ISO8601DateFormatter() + isoFormatterNoMillis.formatOptions = [.withInternetDateTime] + var date: Date? + if let parsedDate = isoFormatter.date(from: dateString) { + date = parsedDate + } else if let parsedDate = isoFormatterNoMillis.date(from: dateString) { + date = parsedDate + } + if let date = date { + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .medium + displayFormatter.timeStyle = .short + displayFormatter.locale = .autoupdatingCurrent + return displayFormatter.string(from: date) + } + return dateString + } +} + +#Preview { + NavigationStack { + AnnotationsListView(bookmarkId: "123") + } +} diff --git a/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift b/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift new file mode 100644 index 0000000..aede002 --- /dev/null +++ b/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift @@ -0,0 +1,29 @@ +import Foundation + +@Observable +class AnnotationsListViewModel { + private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase + + var annotations: [Annotation] = [] + var isLoading = false + var errorMessage: String? + var showErrorAlert = false + + init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase() + } + + @MainActor + func loadAnnotations(for bookmarkId: String) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + annotations = try await getAnnotationsUseCase.execute(bookmarkId: bookmarkId) + } catch { + errorMessage = "Failed to load annotations" + showErrorAlert = true + } + } +} diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift index 34c4154..90e76d8 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -29,18 +29,19 @@ struct BookmarkDetailLegacyView: View { @State private var initialContentEndPosition: CGFloat = 0 @State private var showingFontSettings = false @State private var showingLabelsSheet = false + @State private var showingAnnotationsSheet = false @State private var readingProgress: Double = 0.0 @State private var lastSentProgress: Double = 0.0 @State private var showJumpToProgressButton: Bool = false @State private var scrollPosition = ScrollPosition(edge: .top) @State private var showingImageViewer = false - + // MARK: - Envs - + @EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var appSettings: AppSettings @Environment(\.dismiss) private var dismiss - + private let headerHeight: CGFloat = 360 init(bookmarkId: String, useNativeWebView: Binding, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) { @@ -86,7 +87,8 @@ struct BookmarkDetailLegacyView: View { if webViewHeight != height { webViewHeight = height } - } + }, + selectedAnnotationId: viewModel.selectedAnnotationId ) .frame(height: webViewHeight) .cornerRadius(14) @@ -220,6 +222,12 @@ struct BookmarkDetailLegacyView: View { Image(systemName: "tag") } + Button(action: { + showingAnnotationsSheet = true + }) { + Image(systemName: "pencil.line") + } + Button(action: { showingFontSettings = true }) { @@ -252,6 +260,11 @@ struct BookmarkDetailLegacyView: View { .sheet(isPresented: $showingLabelsSheet) { BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels) } + .sheet(isPresented: $showingAnnotationsSheet) { + AnnotationsListView(bookmarkId: bookmarkId) { annotationId in + viewModel.selectedAnnotationId = annotationId + } + } .sheet(isPresented: $showingImageViewer) { ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) } @@ -271,9 +284,20 @@ struct BookmarkDetailLegacyView: View { } } } + .onChange(of: showingAnnotationsSheet) { _, isShowing in + if !isShowing { + // Reload bookmark detail when labels sheet is dismissed + Task { + await viewModel.refreshBookmarkDetail(id: bookmarkId) + } + } + } .onChange(of: viewModel.readProgress) { _, progress in showJumpToProgressButton = progress > 0 && progress < 100 } + .onChange(of: viewModel.selectedAnnotationId) { _, _ in + // Trigger WebView reload when annotation is selected + } .task { await viewModel.loadBookmarkDetail(id: bookmarkId) await viewModel.loadArticleContent(id: bookmarkId) diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index b37f463..852bd1e 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -14,6 +14,7 @@ struct BookmarkDetailView2: View { @State private var initialContentEndPosition: CGFloat = 0 @State private var showingFontSettings = false @State private var showingLabelsSheet = false + @State private var showingAnnotationsSheet = false @State private var readingProgress: Double = 0.0 @State private var lastSentProgress: Double = 0.0 @State private var showJumpToProgressButton: Bool = false @@ -50,6 +51,11 @@ struct BookmarkDetailView2: View { .sheet(isPresented: $showingLabelsSheet) { BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels) } + .sheet(isPresented: $showingAnnotationsSheet) { + AnnotationsListView(bookmarkId: bookmarkId) { annotationId in + viewModel.selectedAnnotationId = annotationId + } + } .sheet(isPresented: $showingImageViewer) { ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) } @@ -67,9 +73,19 @@ struct BookmarkDetailView2: View { } } } + .onChange(of: showingAnnotationsSheet) { _, isShowing in + if !isShowing { + Task { + await viewModel.refreshBookmarkDetail(id: bookmarkId) + } + } + } .onChange(of: viewModel.readProgress) { _, progress in showJumpToProgressButton = progress > 0 && progress < 100 } + .onChange(of: viewModel.selectedAnnotationId) { _, _ in + // Trigger WebView reload when annotation is selected + } .task { await viewModel.loadBookmarkDetail(id: bookmarkId) await viewModel.loadArticleContent(id: bookmarkId) @@ -254,6 +270,12 @@ struct BookmarkDetailView2: View { Image(systemName: "tag") } + Button(action: { + showingAnnotationsSheet = true + }) { + Image(systemName: "pencil.line") + } + Button(action: { showingFontSettings = true }) { @@ -437,7 +459,8 @@ struct BookmarkDetailView2: View { if webViewHeight != height { webViewHeight = height } - } + }, + selectedAnnotationId: viewModel.selectedAnnotationId ) .frame(height: webViewHeight) .cornerRadius(14) diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift index e76899e..2ba209f 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift @@ -8,7 +8,7 @@ class BookmarkDetailViewModel { private let loadSettingsUseCase: PLoadSettingsUseCase private let updateBookmarkUseCase: PUpdateBookmarkUseCase private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase? - + var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var articleContent: String = "" var articleParagraphs: [String] = [] @@ -18,7 +18,8 @@ class BookmarkDetailViewModel { var errorMessage: String? var settings: Settings? var readProgress: Int = 0 - + var selectedAnnotationId: String? + private var factory: UseCaseFactory? private var cancellables = Set() private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>() @@ -29,7 +30,7 @@ class BookmarkDetailViewModel { self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() self.factory = factory - + readProgressSubject .debounce(for: .seconds(1), scheduler: DispatchQueue.main) .sink { [weak self] (id, progress, anchor) in @@ -67,17 +68,17 @@ class BookmarkDetailViewModel { @MainActor func loadArticleContent(id: String) async { isLoadingArticle = true - + do { articleContent = try await getBookmarkArticleUseCase.execute(id: id) processArticleContent() } catch { errorMessage = "Error loading article" } - + isLoadingArticle = false } - + private func processArticleContent() { let paragraphs = articleContent .components(separatedBy: .newlines) diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift index 507d3b9..6ba8c48 100644 --- a/readeck/UI/Components/NativeWebView.swift +++ b/readeck/UI/Components/NativeWebView.swift @@ -11,7 +11,8 @@ struct NativeWebView: View { let settings: Settings let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil - + var selectedAnnotationId: String? + @State private var webPage = WebPage() @Environment(\.colorScheme) private var colorScheme @@ -27,6 +28,9 @@ struct NativeWebView: View { .onChange(of: colorScheme) { _, _ in loadStyledContent() } + .onChange(of: selectedAnnotationId) { _, _ in + loadStyledContent() + } .onChange(of: webPage.isLoading) { _, isLoading in if !isLoading { // Update height when content finishes loading @@ -197,6 +201,49 @@ struct NativeWebView: View { th { font-weight: 600; } hr { border: none; height: 1px; background-color: #ccc; margin: 24px 0; } + + /* Annotation Highlighting - for rd-annotation tags */ + rd-annotation { + border-radius: 3px; + padding: 2px 0; + transition: background-color 0.3s ease, box-shadow 0.3s ease; + } + + /* Yellow annotations */ + rd-annotation[data-annotation-color="yellow"] { + background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)"); + } + rd-annotation[data-annotation-color="yellow"].selected { + background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.6)" : "rgba(107, 79, 3, 0.5)"); + box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(158, 117, 4, 0.5)" : "rgba(107, 79, 3, 0.6)"); + } + + /* Green annotations */ + rd-annotation[data-annotation-color="green"] { + background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)"); + } + rd-annotation[data-annotation-color="green"].selected { + background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.6)" : "rgba(57, 88, 9, 0.5)"); + box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(132, 204, 22, 0.5)" : "rgba(57, 88, 9, 0.6)"); + } + + /* Blue annotations */ + rd-annotation[data-annotation-color="blue"] { + background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)"); + } + rd-annotation[data-annotation-color="blue"].selected { + background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.6)" : "rgba(7, 95, 116, 0.5)"); + box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(9, 132, 159, 0.5)" : "rgba(7, 95, 116, 0.6)"); + } + + /* Red annotations */ + rd-annotation[data-annotation-color="red"] { + background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)"); + } + rd-annotation[data-annotation-color="red"].selected { + background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.6)" : "rgba(103, 29, 29, 0.5)"); + box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(152, 43, 43, 0.5)" : "rgba(103, 29, 29, 0.6)"); + } @@ -242,6 +289,9 @@ struct NativeWebView: View { } scheduleHeightCheck(); + + // Scroll to selected annotation + \(generateScrollToAnnotationJS()) @@ -273,6 +323,37 @@ struct NativeWebView: View { case .monospace: return "'SF Mono', Menlo, Monaco, monospace" } } + + private func generateScrollToAnnotationJS() -> String { + guard let selectedId = selectedAnnotationId else { + return "" + } + + return """ + // Scroll to selected annotation and add selected class + function scrollToAnnotation() { + // Remove 'selected' class from all annotations + document.querySelectorAll('rd-annotation.selected').forEach(el => { + el.classList.remove('selected'); + }); + + // Find and highlight selected annotation + const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]'); + if (selectedElement) { + selectedElement.classList.add('selected'); + setTimeout(() => { + selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', scrollToAnnotation); + } else { + setTimeout(scrollToAnnotation, 300); + } + """ + } } // MARK: - Hybrid WebView (Not Currently Used) diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index e59c281..4043ee0 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -6,6 +6,7 @@ struct WebView: UIViewRepresentable { let settings: Settings let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil + var selectedAnnotationId: String? @Environment(\.colorScheme) private var colorScheme func makeUIView(context: Context) -> WKWebView { @@ -235,6 +236,49 @@ struct WebView: UIViewRepresentable { --separator-color: #e0e0e0; } } + + /* Annotation Highlighting - for rd-annotation tags */ + rd-annotation { + border-radius: 3px; + padding: 2px 0; + transition: background-color 0.3s ease, box-shadow 0.3s ease; + } + + /* Yellow annotations */ + rd-annotation[data-annotation-color="yellow"] { + background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)"); + } + rd-annotation[data-annotation-color="yellow"].selected { + background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.6)" : "rgba(107, 79, 3, 0.5)"); + box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(158, 117, 4, 0.5)" : "rgba(107, 79, 3, 0.6)"); + } + + /* Green annotations */ + rd-annotation[data-annotation-color="green"] { + background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)"); + } + rd-annotation[data-annotation-color="green"].selected { + background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.6)" : "rgba(57, 88, 9, 0.5)"); + box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(132, 204, 22, 0.5)" : "rgba(57, 88, 9, 0.6)"); + } + + /* Blue annotations */ + rd-annotation[data-annotation-color="blue"] { + background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)"); + } + rd-annotation[data-annotation-color="blue"].selected { + background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.6)" : "rgba(7, 95, 116, 0.5)"); + box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(9, 132, 159, 0.5)" : "rgba(7, 95, 116, 0.6)"); + } + + /* Red annotations */ + rd-annotation[data-annotation-color="red"] { + background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)"); + } + rd-annotation[data-annotation-color="red"].selected { + background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.6)" : "rgba(103, 29, 29, 0.5)"); + box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(152, 43, 43, 0.5)" : "rgba(103, 29, 29, 0.6)"); + } @@ -264,6 +308,9 @@ struct WebView: UIViewRepresentable { document.querySelectorAll('img').forEach(img => { img.addEventListener('load', debouncedHeightUpdate); }); + + // Scroll to selected annotation + \(generateScrollToAnnotationJS()) @@ -305,6 +352,37 @@ struct WebView: UIViewRepresentable { return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace" } } + + private func generateScrollToAnnotationJS() -> String { + guard let selectedId = selectedAnnotationId else { + return "" + } + + return """ + // Scroll to selected annotation and add selected class + function scrollToAnnotation() { + // Remove 'selected' class from all annotations + document.querySelectorAll('rd-annotation.selected').forEach(el => { + el.classList.remove('selected'); + }); + + // Find and highlight selected annotation + const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]'); + if (selectedElement) { + selectedElement.classList.add('selected'); + setTimeout(() => { + selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', scrollToAnnotation); + } else { + setTimeout(scrollToAnnotation, 300); + } + """ + } } class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index bef448c..1a69ce2 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -21,6 +21,7 @@ protocol UseCaseFactory { func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase + func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase } @@ -33,6 +34,7 @@ class DefaultUseCaseFactory: UseCaseFactory { private let settingsRepository: PSettingsRepository = SettingsRepository() private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider) private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient) + private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api) static let shared = DefaultUseCaseFactory() @@ -119,4 +121,8 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase { return CheckServerReachabilityUseCase(repository: serverInfoRepository) } + + func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase { + return GetBookmarkAnnotationsUseCase(repository: annotationsRepository) + } } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index 61dba4a..6bf191c 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -88,6 +88,10 @@ class MockUseCaseFactory: UseCaseFactory { func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase { MockSaveCardLayoutUseCase() } + + func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase { + MockGetBookmarkAnnotationsUseCase() + } } @@ -238,6 +242,14 @@ class MockCheckServerReachabilityUseCase: PCheckServerReachabilityUseCase { } } +class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase { + func execute(bookmarkId: String) async throws -> [Annotation] { + return [ + .init(id: "1", text: "bla", created: "", startOffset: 0, endOffset: 1, startSelector: "", endSelector: "") + ] + } +} + extension Bookmark { static let mock: Bookmark = .init( id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil) diff --git a/readeck/UI/Menu/PadSidebarView.swift b/readeck/UI/Menu/PadSidebarView.swift index a373b48..8683678 100644 --- a/readeck/UI/Menu/PadSidebarView.swift +++ b/readeck/UI/Menu/PadSidebarView.swift @@ -87,11 +87,11 @@ struct PadSidebarView: View { case .all: BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) case .unread: - BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark) + BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) case .favorite: - BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark) + BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) case .archived: - BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark) + BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) case .settings: SettingsView() case .article: diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index dbd2f0d..fde9a15 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -153,7 +153,7 @@ struct PhoneTabView: View { // Hidden NavigationLink to remove disclosure indicator NavigationLink { - BookmarkDetailView(bookmarkId: bookmark.id) + BookmarkDetailView(bookmarkId: bookmark.id) } label: { EmptyView() } @@ -234,11 +234,11 @@ struct PhoneTabView: View { case .all: BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) case .unread: - BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil)) + BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) case .favorite: - BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil)) + BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) case .archived: - BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil)) + BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) case .search: EmptyView() // search is directly implemented case .settings: From ec12815a51e96f481e671515fb52bc446a15cb0a Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 22 Oct 2025 15:30:34 +0200 Subject: [PATCH 16/39] feat: Add text selection and annotation creation UI Implement interactive text annotation feature: - Add text selection detection via JavaScript in WebView - Create AnnotationColorPicker with 4 color options (yellow, green, blue, red) - Integrate color picker sheet in bookmark detail views - Calculate text offsets for precise annotation positioning - Add onTextSelected callback for WebView component - Prepare UI for future API integration Users can now select text in articles and choose a highlight color. API integration for persisting annotations will follow. --- .../AnnotationColorPicker.swift | 84 +++++++++++++++++++ .../BookmarkDetailLegacyView.swift | 19 ++++- .../BookmarkDetail/BookmarkDetailView2.swift | 11 +++ readeck/UI/Components/WebView.swift | 35 ++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 readeck/UI/BookmarkDetail/AnnotationColorPicker.swift diff --git a/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift b/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift new file mode 100644 index 0000000..ae410da --- /dev/null +++ b/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct AnnotationColorPicker: View { + let selectedText: String + let onColorSelected: (AnnotationColor) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 16) { + Text("Highlight Text") + .font(.headline) + + Text(selectedText) + .font(.body) + .foregroundColor(.secondary) + .lineLimit(3) + .padding() + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + + Text("Select Color") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack(spacing: 16) { + ColorButton(color: .yellow, onTap: handleColorSelection) + ColorButton(color: .green, onTap: handleColorSelection) + ColorButton(color: .blue, onTap: handleColorSelection) + ColorButton(color: .red, onTap: handleColorSelection) + } + + Button("Cancel") { + dismiss() + } + .foregroundColor(.secondary) + } + .padding(24) + .frame(maxWidth: 400) + } + + private func handleColorSelection(_ color: AnnotationColor) { + onColorSelected(color) + dismiss() + } +} + +struct ColorButton: View { + let color: AnnotationColor + let onTap: (AnnotationColor) -> Void + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: { onTap(color) }) { + Circle() + .fill(color.swiftUIColor(isDark: colorScheme == .dark)) + .frame(width: 50, height: 50) + .overlay( + Circle() + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + ) + } + } +} + +enum AnnotationColor: String, CaseIterable { + case yellow = "yellow" + case green = "green" + case blue = "blue" + case red = "red" + + func swiftUIColor(isDark: Bool) -> Color { + switch self { + case .yellow: + return isDark ? Color(red: 158/255, green: 117/255, blue: 4/255) : Color(red: 107/255, green: 79/255, blue: 3/255) + case .green: + return isDark ? Color(red: 132/255, green: 204/255, blue: 22/255) : Color(red: 57/255, green: 88/255, blue: 9/255) + case .blue: + return isDark ? Color(red: 9/255, green: 132/255, blue: 159/255) : Color(red: 7/255, green: 95/255, blue: 116/255) + case .red: + return isDark ? Color(red: 152/255, green: 43/255, blue: 43/255) : Color(red: 103/255, green: 29/255, blue: 29/255) + } + } +} diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift index 90e76d8..60e051c 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -35,6 +35,10 @@ struct BookmarkDetailLegacyView: View { @State private var showJumpToProgressButton: Bool = false @State private var scrollPosition = ScrollPosition(edge: .top) @State private var showingImageViewer = false + @State private var showingColorPicker = false + @State private var selectedText: String = "" + @State private var selectedStartOffset: Int = 0 + @State private var selectedEndOffset: Int = 0 // MARK: - Envs @@ -88,7 +92,13 @@ struct BookmarkDetailLegacyView: View { webViewHeight = height } }, - selectedAnnotationId: viewModel.selectedAnnotationId + selectedAnnotationId: viewModel.selectedAnnotationId, + onTextSelected: { text, startOffset, endOffset in + selectedText = text + selectedStartOffset = startOffset + selectedEndOffset = endOffset + showingColorPicker = true + } ) .frame(height: webViewHeight) .cornerRadius(14) @@ -268,6 +278,13 @@ struct BookmarkDetailLegacyView: View { .sheet(isPresented: $showingImageViewer) { ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) } + .sheet(isPresented: $showingColorPicker) { + AnnotationColorPicker(selectedText: selectedText) { color in + // TODO: API call to create annotation will go here + print("Creating annotation with color: \(color.rawValue), offsets: \(selectedStartOffset)-\(selectedEndOffset)") + } + .presentationDetents([.height(300)]) + } .onChange(of: showingFontSettings) { _, isShowing in if !isShowing { // Reload settings when sheet is dismissed diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index 852bd1e..043ba27 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -20,6 +20,10 @@ struct BookmarkDetailView2: View { @State private var showJumpToProgressButton: Bool = false @State private var scrollPosition = ScrollPosition(edge: .top) @State private var showingImageViewer = false + @State private var showingColorPicker = false + @State private var selectedText: String = "" + @State private var selectedStartOffset: Int = 0 + @State private var selectedEndOffset: Int = 0 // MARK: - Envs @@ -59,6 +63,13 @@ struct BookmarkDetailView2: View { .sheet(isPresented: $showingImageViewer) { ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) } + .sheet(isPresented: $showingColorPicker) { + AnnotationColorPicker(selectedText: selectedText) { color in + // TODO: API call to create annotation will go here + print("Creating annotation with color: \(color.rawValue), offsets: \(selectedStartOffset)-\(selectedEndOffset)") + } + .presentationDetents([.height(300)]) + } .onChange(of: showingFontSettings) { _, isShowing in if !isShowing { Task { diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index 4043ee0..40822f0 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -7,6 +7,7 @@ struct WebView: UIViewRepresentable { let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil var selectedAnnotationId: String? + var onTextSelected: ((String, Int, Int) -> Void)? = nil @Environment(\.colorScheme) private var colorScheme func makeUIView(context: Context) -> WKWebView { @@ -29,8 +30,10 @@ 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: "textSelected") context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll + context.coordinator.onTextSelected = onTextSelected return webView } @@ -38,6 +41,7 @@ struct WebView: UIViewRepresentable { func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll + context.coordinator.onTextSelected = onTextSelected let isDarkMode = colorScheme == .dark let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) @@ -309,6 +313,28 @@ struct WebView: UIViewRepresentable { img.addEventListener('load', debouncedHeightUpdate); }); + // Text selection detection + document.addEventListener('selectionchange', function() { + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + const range = selection.getRangeAt(0); + const selectedText = selection.toString(); + + // Calculate character offset from start of body + const preRange = document.createRange(); + preRange.selectNodeContents(document.body); + preRange.setEnd(range.startContainer, range.startOffset); + const startOffset = preRange.toString().length; + const endOffset = startOffset + selectedText.length; + + window.webkit.messageHandlers.textSelected.postMessage({ + text: selectedText, + startOffset: startOffset, + endOffset: endOffset + }); + } + }); + // Scroll to selected annotation \(generateScrollToAnnotationJS()) @@ -389,6 +415,7 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler // Callbacks var onHeightChange: ((CGFloat) -> Void)? var onScroll: ((Double) -> Void)? + var onTextSelected: ((String, Int, Int) -> Void)? // Height management var lastHeight: CGFloat = 0 @@ -430,6 +457,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler self.handleScrollProgress(progress: progress) } } + if message.name == "textSelected", let body = message.body as? [String: Any], + let text = body["text"] as? String, + let startOffset = body["startOffset"] as? Int, + let endOffset = body["endOffset"] as? Int { + DispatchQueue.main.async { + self.onTextSelected?(text, startOffset, endOffset) + } + } } private func handleHeightUpdate(height: CGFloat) { From a041300b4f20fedaa28ed8470ea55b9e8d253473 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 22 Oct 2025 15:35:56 +0200 Subject: [PATCH 17/39] feat: Add text selection support for iOS 26+ NativeWebView Implement text selection detection in NativeWebView: - Add onTextSelected callback parameter to NativeWebView - Use JavaScript polling to detect text selections - Calculate text offsets for precise annotation positioning - Integrate color picker in BookmarkDetailView2 for iOS 26+ - Match feature parity with legacy WebView implementation Text selection now works on both WebView implementations. --- .../BookmarkDetail/BookmarkDetailView2.swift | 8 ++- readeck/UI/Components/NativeWebView.swift | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index 043ba27..a5b355c 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -471,7 +471,13 @@ struct BookmarkDetailView2: View { webViewHeight = height } }, - selectedAnnotationId: viewModel.selectedAnnotationId + selectedAnnotationId: viewModel.selectedAnnotationId, + onTextSelected: { text, startOffset, endOffset in + selectedText = text + selectedStartOffset = startOffset + selectedEndOffset = endOffset + showingColorPicker = true + } ) .frame(height: webViewHeight) .cornerRadius(14) diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift index 6ba8c48..f0ebd0b 100644 --- a/readeck/UI/Components/NativeWebView.swift +++ b/readeck/UI/Components/NativeWebView.swift @@ -12,6 +12,7 @@ struct NativeWebView: View { let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil var selectedAnnotationId: String? + var onTextSelected: ((String, Int, Int) -> Void)? = nil @State private var webPage = WebPage() @Environment(\.colorScheme) private var colorScheme @@ -21,6 +22,7 @@ struct NativeWebView: View { .scrollDisabled(true) // Disable internal scrolling .onAppear { loadStyledContent() + setupTextSelectionCallback() } .onChange(of: htmlContent) { _, _ in loadStyledContent() @@ -42,6 +44,53 @@ struct NativeWebView: View { } } } + + private func setupTextSelectionCallback() { + guard let onTextSelected = onTextSelected else { return } + + // Poll for text selection using JavaScript + Task { + while true { + try? await Task.sleep(nanoseconds: 300_000_000) // Check every 0.3s + + let script = """ + (function() { + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + const range = selection.getRangeAt(0); + const selectedText = selection.toString(); + + const preRange = document.createRange(); + preRange.selectNodeContents(document.body); + preRange.setEnd(range.startContainer, range.startOffset); + const startOffset = preRange.toString().length; + const endOffset = startOffset + selectedText.length; + + return { + text: selectedText, + startOffset: startOffset, + endOffset: endOffset + }; + } + return null; + })(); + """ + + do { + if let result = try await webPage.evaluateJavaScript(script) as? [String: Any], + let text = result["text"] as? String, + let startOffset = result["startOffset"] as? Int, + let endOffset = result["endOffset"] as? Int { + await MainActor.run { + onTextSelected(text, startOffset, endOffset) + } + } + } catch { + // Silently continue polling + } + } + } + } private func updateContentHeightWithJS() async { var lastHeight: CGFloat = 0 @@ -290,6 +339,9 @@ struct NativeWebView: View { scheduleHeightCheck(); + // Text selection detection + \(generateTextSelectionJS()) + // Scroll to selected annotation \(generateScrollToAnnotationJS()) @@ -324,6 +376,11 @@ struct NativeWebView: View { } } + private func generateTextSelectionJS() -> String { + // Not needed for iOS 26 - we use polling instead + return "" + } + private func generateScrollToAnnotationJS() -> String { guard let selectedId = selectedAnnotationId else { return "" From d1157defbed6e2b4e6d9776e637e1f938129a629 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 22 Oct 2025 15:54:55 +0200 Subject: [PATCH 18/39] fix: Resolve WebPage binding error in NativeWebView text selection Capture webPage locally in Task to avoid @State binding issues when calling evaluateJavaScript in async context. --- readeck/UI/Components/NativeWebView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift index f0ebd0b..00ac47e 100644 --- a/readeck/UI/Components/NativeWebView.swift +++ b/readeck/UI/Components/NativeWebView.swift @@ -49,7 +49,9 @@ struct NativeWebView: View { guard let onTextSelected = onTextSelected else { return } // Poll for text selection using JavaScript - Task { + Task { @MainActor in + let page = webPage // Capture the webPage + while true { try? await Task.sleep(nanoseconds: 300_000_000) // Check every 0.3s @@ -77,13 +79,11 @@ struct NativeWebView: View { """ do { - if let result = try await webPage.evaluateJavaScript(script) as? [String: Any], + if let result = try await page.evaluateJavaScript(script) as? [String: Any], let text = result["text"] as? String, let startOffset = result["startOffset"] as? Int, let endOffset = result["endOffset"] as? Int { - await MainActor.run { - onTextSelected(text, startOffset, endOffset) - } + onTextSelected(text, startOffset, endOffset) } } catch { // Silently continue polling From 1b9f79bccc1b4ee2be21fc7c2092d46c56114e66 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 22 Oct 2025 15:58:07 +0200 Subject: [PATCH 19/39] fix: Use callJavaScript instead of evaluateJavaScript for WebPage WebPage in iOS 26 uses callJavaScript method, not evaluateJavaScript. --- readeck/UI/Components/NativeWebView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift index 00ac47e..a8270b4 100644 --- a/readeck/UI/Components/NativeWebView.swift +++ b/readeck/UI/Components/NativeWebView.swift @@ -56,7 +56,7 @@ struct NativeWebView: View { try? await Task.sleep(nanoseconds: 300_000_000) // Check every 0.3s let script = """ - (function() { + return (function() { const selection = window.getSelection(); if (selection && selection.toString().length > 0) { const range = selection.getRangeAt(0); @@ -79,7 +79,7 @@ struct NativeWebView: View { """ do { - if let result = try await page.evaluateJavaScript(script) as? [String: Any], + if let result = try await page.callJavaScript(script) as? [String: Any], let text = result["text"] as? String, let startOffset = result["startOffset"] as? Int, let endOffset = result["endOffset"] as? Int { From b77e4e3e9fcd9916630567d8d9d8292504def34f Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 25 Oct 2025 09:19:49 +0200 Subject: [PATCH 20/39] refactor: Centralize annotation colors and improve color consistency - Move AnnotationColor enum to Constants.swift for centralized color management - Add hexColor property to provide hex values for JavaScript overlays - Add cssColorWithOpacity method for flexible opacity control - Update NativeWebView and WebView to use centralized color values - Replace modal color picker with inline overlay for better UX - Implement annotation creation directly from text selection - Add API endpoint for creating annotations with selectors --- readeck/Data/API/API.swift | 27 ++ .../AnnotationColorOverlay.swift | 45 +++ .../AnnotationColorPicker.swift | 27 +- .../BookmarkDetailLegacyView.swift | 28 +- .../BookmarkDetail/BookmarkDetailView2.swift | 28 +- .../BookmarkDetailViewModel.swift | 20 ++ readeck/UI/Components/Constants.swift | 48 ++- readeck/UI/Components/NativeWebView.swift | 315 ++++++++++++++--- readeck/UI/Components/WebView.swift | 326 +++++++++++++++--- 9 files changed, 720 insertions(+), 144 deletions(-) create mode 100644 readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index 901d247..97cabd9 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -19,6 +19,7 @@ protocol PAPI { func searchBookmarks(search: String) async throws -> BookmarksPageDto func getBookmarkLabels() async throws -> [BookmarkLabelDto] func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] + func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto } class API: PAPI { @@ -459,6 +460,32 @@ class API: PAPI { logger.info("Successfully fetched \(result.count) annotations for bookmark: \(bookmarkId)") return result } + + func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto { + logger.debug("Creating annotation for bookmark: \(bookmarkId)") + let endpoint = "/api/bookmarks/\(bookmarkId)/annotations" + logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint) + + let bodyDict: [String: Any] = [ + "color": color, + "start_offset": startOffset, + "end_offset": endOffset, + "start_selector": startSelector, + "end_selector": endSelector + ] + + let bodyData = try JSONSerialization.data(withJSONObject: bodyDict, options: []) + + let result = try await makeJSONRequest( + endpoint: endpoint, + method: .POST, + body: bodyData, + responseType: AnnotationDto.self + ) + + logger.info("Successfully created annotation for bookmark: \(bookmarkId)") + return result + } } enum HTTPMethod: String { diff --git a/readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift b/readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift new file mode 100644 index 0000000..079dcbd --- /dev/null +++ b/readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct AnnotationColorOverlay: View { + let onColorSelected: (AnnotationColor) -> Void + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 8) { + ForEach(Constants.annotationColors, id: \.self) { color in + ColorButton(color: color, onTap: onColorSelected) + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.ultraThinMaterial) + .shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2) + ) + } + + private struct ColorButton: View { + let color: AnnotationColor + let onTap: (AnnotationColor) -> Void + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: { onTap(color) }) { + Circle() + .fill(color.swiftUIColor(isDark: colorScheme == .dark)) + .frame(width: 36, height: 36) + .overlay( + Circle() + .stroke(Color.primary.opacity(0.15), lineWidth: 1) + ) + } + } + } +} + +#Preview { + AnnotationColorOverlay { color in + print("Selected: \(color)") + } + .padding() +} diff --git a/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift b/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift index ae410da..802b8d0 100644 --- a/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift +++ b/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift @@ -24,10 +24,9 @@ struct AnnotationColorPicker: View { .foregroundColor(.secondary) HStack(spacing: 16) { - ColorButton(color: .yellow, onTap: handleColorSelection) - ColorButton(color: .green, onTap: handleColorSelection) - ColorButton(color: .blue, onTap: handleColorSelection) - ColorButton(color: .red, onTap: handleColorSelection) + ForEach(Constants.annotationColors, id: \.self) { color in + ColorButton(color: color, onTap: handleColorSelection) + } } Button("Cancel") { @@ -62,23 +61,3 @@ struct ColorButton: View { } } } - -enum AnnotationColor: String, CaseIterable { - case yellow = "yellow" - case green = "green" - case blue = "blue" - case red = "red" - - func swiftUIColor(isDark: Bool) -> Color { - switch self { - case .yellow: - return isDark ? Color(red: 158/255, green: 117/255, blue: 4/255) : Color(red: 107/255, green: 79/255, blue: 3/255) - case .green: - return isDark ? Color(red: 132/255, green: 204/255, blue: 22/255) : Color(red: 57/255, green: 88/255, blue: 9/255) - case .blue: - return isDark ? Color(red: 9/255, green: 132/255, blue: 159/255) : Color(red: 7/255, green: 95/255, blue: 116/255) - case .red: - return isDark ? Color(red: 152/255, green: 43/255, blue: 43/255) : Color(red: 103/255, green: 29/255, blue: 29/255) - } - } -} diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift index 60e051c..b44becd 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -35,10 +35,6 @@ struct BookmarkDetailLegacyView: View { @State private var showJumpToProgressButton: Bool = false @State private var scrollPosition = ScrollPosition(edge: .top) @State private var showingImageViewer = false - @State private var showingColorPicker = false - @State private var selectedText: String = "" - @State private var selectedStartOffset: Int = 0 - @State private var selectedEndOffset: Int = 0 // MARK: - Envs @@ -93,11 +89,18 @@ struct BookmarkDetailLegacyView: View { } }, selectedAnnotationId: viewModel.selectedAnnotationId, - onTextSelected: { text, startOffset, endOffset in - selectedText = text - selectedStartOffset = startOffset - selectedEndOffset = endOffset - showingColorPicker = true + onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in + Task { + await viewModel.createAnnotation( + bookmarkId: bookmarkId, + color: color, + text: text, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + ) + } } ) .frame(height: webViewHeight) @@ -278,13 +281,6 @@ struct BookmarkDetailLegacyView: View { .sheet(isPresented: $showingImageViewer) { ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) } - .sheet(isPresented: $showingColorPicker) { - AnnotationColorPicker(selectedText: selectedText) { color in - // TODO: API call to create annotation will go here - print("Creating annotation with color: \(color.rawValue), offsets: \(selectedStartOffset)-\(selectedEndOffset)") - } - .presentationDetents([.height(300)]) - } .onChange(of: showingFontSettings) { _, isShowing in if !isShowing { // Reload settings when sheet is dismissed diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index a5b355c..558ffc5 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -20,10 +20,6 @@ struct BookmarkDetailView2: View { @State private var showJumpToProgressButton: Bool = false @State private var scrollPosition = ScrollPosition(edge: .top) @State private var showingImageViewer = false - @State private var showingColorPicker = false - @State private var selectedText: String = "" - @State private var selectedStartOffset: Int = 0 - @State private var selectedEndOffset: Int = 0 // MARK: - Envs @@ -63,13 +59,6 @@ struct BookmarkDetailView2: View { .sheet(isPresented: $showingImageViewer) { ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) } - .sheet(isPresented: $showingColorPicker) { - AnnotationColorPicker(selectedText: selectedText) { color in - // TODO: API call to create annotation will go here - print("Creating annotation with color: \(color.rawValue), offsets: \(selectedStartOffset)-\(selectedEndOffset)") - } - .presentationDetents([.height(300)]) - } .onChange(of: showingFontSettings) { _, isShowing in if !isShowing { Task { @@ -472,11 +461,18 @@ struct BookmarkDetailView2: View { } }, selectedAnnotationId: viewModel.selectedAnnotationId, - onTextSelected: { text, startOffset, endOffset in - selectedText = text - selectedStartOffset = startOffset - selectedEndOffset = endOffset - showingColorPicker = true + onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in + Task { + await viewModel.createAnnotation( + bookmarkId: bookmarkId, + color: color, + text: text, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + ) + } } ) .frame(height: webViewHeight) diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift index 2ba209f..689df7a 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift @@ -8,6 +8,7 @@ class BookmarkDetailViewModel { private let loadSettingsUseCase: PLoadSettingsUseCase private let updateBookmarkUseCase: PUpdateBookmarkUseCase private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase? + private let api: PAPI var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var articleContent: String = "" @@ -29,6 +30,7 @@ class BookmarkDetailViewModel { self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() + self.api = API() self.factory = factory readProgressSubject @@ -138,4 +140,22 @@ class BookmarkDetailViewModel { func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) { readProgressSubject.send((id, progress, anchor)) } + + @MainActor + func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async { + do { + let annotation = try await api.createAnnotation( + bookmarkId: bookmarkId, + color: color, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + ) + print("βœ… Annotation created: \(annotation.id)") + } catch { + print("❌ Failed to create annotation: \(error)") + errorMessage = "Error creating annotation" + } + } } diff --git a/readeck/UI/Components/Constants.swift b/readeck/UI/Components/Constants.swift index 09b31f5..d820412 100644 --- a/readeck/UI/Components/Constants.swift +++ b/readeck/UI/Components/Constants.swift @@ -10,7 +10,53 @@ // import Foundation +import SwiftUI struct Constants { - // Empty for now - can be used for other constants in the future + // Annotation colors + static let annotationColors: [AnnotationColor] = [.yellow, .green, .blue, .red] +} + +enum AnnotationColor: String, CaseIterable, Codable { + case yellow = "yellow" + case green = "green" + case blue = "blue" + case red = "red" + + // Base hex color for buttons and overlays + var hexColor: String { + switch self { + case .yellow: return "#D4A843" + case .green: return "#6FB546" + case .blue: return "#4A9BB8" + case .red: return "#C84848" + } + } + + // RGB values for SwiftUI Color + private var rgb: (red: Double, green: Double, blue: Double) { + switch self { + case .yellow: return (212, 168, 67) + case .green: return (111, 181, 70) + case .blue: return (74, 155, 184) + case .red: return (200, 72, 72) + } + } + + func swiftUIColor(isDark: Bool) -> Color { + let (r, g, b) = rgb + return Color(red: r/255, green: g/255, blue: b/255) + } + + // CSS rgba string for JavaScript (for highlighting) + func cssColor(isDark: Bool) -> String { + let (r, g, b) = rgb + return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), 0.3)" + } + + // CSS rgba string with custom opacity + func cssColorWithOpacity(_ opacity: Double) -> String { + let (r, g, b) = rgb + return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), \(opacity))" + } } diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift index a8270b4..352c110 100644 --- a/readeck/UI/Components/NativeWebView.swift +++ b/readeck/UI/Components/NativeWebView.swift @@ -12,7 +12,7 @@ struct NativeWebView: View { let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil var selectedAnnotationId: String? - var onTextSelected: ((String, Int, Int) -> Void)? = nil + var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil @State private var webPage = WebPage() @Environment(\.colorScheme) private var colorScheme @@ -22,7 +22,7 @@ struct NativeWebView: View { .scrollDisabled(true) // Disable internal scrolling .onAppear { loadStyledContent() - setupTextSelectionCallback() + setupAnnotationMessageHandler() } .onChange(of: htmlContent) { _, _ in loadStyledContent() @@ -45,34 +45,22 @@ struct NativeWebView: View { } } - private func setupTextSelectionCallback() { - guard let onTextSelected = onTextSelected else { return } + private func setupAnnotationMessageHandler() { + guard let onAnnotationCreated = onAnnotationCreated else { return } - // Poll for text selection using JavaScript + // Poll for annotation messages from JavaScript Task { @MainActor in - let page = webPage // Capture the webPage + let page = webPage while true { - try? await Task.sleep(nanoseconds: 300_000_000) // Check every 0.3s + try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s let script = """ return (function() { - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - const range = selection.getRangeAt(0); - const selectedText = selection.toString(); - - const preRange = document.createRange(); - preRange.selectNodeContents(document.body); - preRange.setEnd(range.startContainer, range.startOffset); - const startOffset = preRange.toString().length; - const endOffset = startOffset + selectedText.length; - - return { - text: selectedText, - startOffset: startOffset, - endOffset: endOffset - }; + if (window.__pendingAnnotation) { + const data = window.__pendingAnnotation; + window.__pendingAnnotation = null; + return data; } return null; })(); @@ -80,10 +68,13 @@ struct NativeWebView: View { do { if let result = try await page.callJavaScript(script) as? [String: Any], + let color = result["color"] as? String, let text = result["text"] as? String, let startOffset = result["startOffset"] as? Int, - let endOffset = result["endOffset"] as? Int { - onTextSelected(text, startOffset, endOffset) + let endOffset = result["endOffset"] as? Int, + let startSelector = result["startSelector"] as? String, + let endSelector = result["endSelector"] as? String { + onAnnotationCreated(color, text, startOffset, endOffset, startSelector, endSelector) } } catch { // Silently continue polling @@ -260,38 +251,38 @@ struct NativeWebView: View { /* Yellow annotations */ rd-annotation[data-annotation-color="yellow"] { - background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)"); + background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="yellow"].selected { - background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.6)" : "rgba(107, 79, 3, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(158, 117, 4, 0.5)" : "rgba(107, 79, 3, 0.6)"); + background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6)); } /* Green annotations */ rd-annotation[data-annotation-color="green"] { - background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)"); + background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="green"].selected { - background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.6)" : "rgba(57, 88, 9, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(132, 204, 22, 0.5)" : "rgba(57, 88, 9, 0.6)"); + background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6)); } /* Blue annotations */ rd-annotation[data-annotation-color="blue"] { - background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)"); + background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="blue"].selected { - background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.6)" : "rgba(7, 95, 116, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(9, 132, 159, 0.5)" : "rgba(7, 95, 116, 0.6)"); + background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6)); } /* Red annotations */ rd-annotation[data-annotation-color="red"] { - background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)"); + background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="red"].selected { - background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.6)" : "rgba(103, 29, 29, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(152, 43, 43, 0.5)" : "rgba(103, 29, 29, 0.6)"); + background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6)); } @@ -339,11 +330,11 @@ struct NativeWebView: View { scheduleHeightCheck(); - // Text selection detection - \(generateTextSelectionJS()) - // Scroll to selected annotation \(generateScrollToAnnotationJS()) + + // Text Selection and Annotation Overlay + \(generateAnnotationOverlayJS(isDarkMode: isDarkMode)) @@ -376,9 +367,247 @@ struct NativeWebView: View { } } - private func generateTextSelectionJS() -> String { - // Not needed for iOS 26 - we use polling instead - return "" + private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String { + return """ + // Create annotation color overlay + (function() { + let currentSelection = null; + let currentRange = null; + let selectionTimeout = null; + + // Create overlay container with arrow + const overlay = document.createElement('div'); + overlay.id = 'annotation-overlay'; + overlay.style.cssText = ` + display: none; + position: absolute; + z-index: 10000; + `; + + // Create arrow/triangle pointing up with glass effect + const arrow = document.createElement('div'); + arrow.style.cssText = ` + position: absolute; + width: 20px; + height: 20px; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-right: none; + border-bottom: none; + top: -11px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + `; + overlay.appendChild(arrow); + + // Create the actual content container with glass morphism effect + const content = document.createElement('div'); + content.style.cssText = ` + display: flex; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 24px; + padding: 12px 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), + 0 2px 8px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.3); + gap: 12px; + flex-direction: row; + align-items: center; + `; + overlay.appendChild(content); + + // Add "Markierung" label + const label = document.createElement('span'); + label.textContent = 'Markierung'; + label.style.cssText = ` + color: black; + font-size: 16px; + font-weight: 500; + margin-right: 4px; + `; + content.appendChild(label); + + // Create color buttons with solid colors + const colors = [ + { name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' }, + { name: 'red', color: '\(AnnotationColor.red.hexColor)' }, + { name: 'blue', color: '\(AnnotationColor.blue.hexColor)' }, + { name: 'green', color: '\(AnnotationColor.green.hexColor)' } + ]; + + colors.forEach(({ name, color }) => { + const btn = document.createElement('button'); + btn.dataset.color = name; + btn.style.cssText = ` + width: 40px; + height: 40px; + border-radius: 50%; + background: ${color}; + border: 3px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 0; + margin: 0; + transition: transform 0.2s, border-color 0.2s; + `; + btn.addEventListener('mouseenter', () => { + btn.style.transform = 'scale(1.1)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.6)'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.transform = 'scale(1)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.3)'; + }); + btn.addEventListener('click', () => handleColorSelection(name)); + content.appendChild(btn); + }); + + document.body.appendChild(overlay); + + // Selection change listener + document.addEventListener('selectionchange', () => { + clearTimeout(selectionTimeout); + selectionTimeout = setTimeout(() => { + const selection = window.getSelection(); + const text = selection.toString().trim(); + + if (text.length > 0) { + currentSelection = text; + currentRange = selection.getRangeAt(0).cloneRange(); + showOverlay(selection.getRangeAt(0)); + } else { + hideOverlay(); + } + }, 150); + }); + + function showOverlay(range) { + const rect = range.getBoundingClientRect(); + const scrollY = window.scrollY || window.pageYOffset; + + overlay.style.display = 'block'; + + // Center horizontally under selection + const overlayWidth = 320; // Approximate width with label + 4 buttons + const centerX = rect.left + (rect.width / 2); + const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8)); + + // Position with extra space below selection (55px instead of 70px) to bring it closer + const topPos = rect.bottom + scrollY + 55; + + overlay.style.left = leftPos + 'px'; + overlay.style.top = topPos + 'px'; + } + + function hideOverlay() { + overlay.style.display = 'none'; + currentSelection = null; + currentRange = null; + } + + function calculateOffset(container, offset) { + const preRange = document.createRange(); + preRange.selectNodeContents(document.body); + preRange.setEnd(container, offset); + return preRange.toString().length; + } + + function getXPathSelector(node) { + // If node is text node, use parent element + const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + if (!element || element === document.body) return 'body'; + + const path = []; + let current = element; + + while (current && current !== document.body) { + const tagName = current.tagName.toLowerCase(); + + // Count position among siblings of same tag (1-based index) + let index = 1; + let sibling = current.previousElementSibling; + while (sibling) { + if (sibling.tagName === current.tagName) { + index++; + } + sibling = sibling.previousElementSibling; + } + + // Format: tagname[index] (1-based) + path.unshift(tagName + '[' + index + ']'); + + current = current.parentElement; + } + + const selector = path.join('/'); + console.log('Generated selector:', selector); + return selector || 'body'; + } + + function calculateOffsetInElement(container, offset) { + // Calculate offset relative to the parent element (not document.body) + const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container; + if (!element) return offset; + + // Create range from start of element to the position + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(container, offset); + + return range.toString().length; + } + + function generateTempId() { + return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); + } + + function handleColorSelection(color) { + if (!currentRange || !currentSelection) return; + + // Generate XPath-like selectors for start and end containers + const startSelector = getXPathSelector(currentRange.startContainer); + const endSelector = getXPathSelector(currentRange.endContainer); + + // Calculate offsets relative to the element (not document.body) + const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset); + const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset); + + // Create annotation element + const annotation = document.createElement('rd-annotation'); + annotation.setAttribute('data-annotation-color', color); + annotation.setAttribute('data-annotation-id-value', generateTempId()); + + // Wrap selection in annotation + try { + currentRange.surroundContents(annotation); + } catch (e) { + // If surroundContents fails (e.g., partial element selection), extract and wrap + const fragment = currentRange.extractContents(); + annotation.appendChild(fragment); + currentRange.insertNode(annotation); + } + + // For NativeWebView: use global variable for polling + window.__pendingAnnotation = { + color: color, + text: currentSelection, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + }; + + // Clear selection and hide overlay + window.getSelection().removeAllRanges(); + hideOverlay(); + } + })(); + """ } private func generateScrollToAnnotationJS() -> String { diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index 40822f0..442f5b1 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -7,7 +7,7 @@ struct WebView: UIViewRepresentable { let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil var selectedAnnotationId: String? - var onTextSelected: ((String, Int, Int) -> Void)? = nil + var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil @Environment(\.colorScheme) private var colorScheme func makeUIView(context: Context) -> WKWebView { @@ -30,10 +30,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: "textSelected") + webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated") context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll - context.coordinator.onTextSelected = onTextSelected + context.coordinator.onAnnotationCreated = onAnnotationCreated + context.coordinator.webView = webView return webView } @@ -41,7 +42,7 @@ struct WebView: UIViewRepresentable { func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll - context.coordinator.onTextSelected = onTextSelected + context.coordinator.onAnnotationCreated = onAnnotationCreated let isDarkMode = colorScheme == .dark let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) @@ -250,38 +251,38 @@ struct WebView: UIViewRepresentable { /* Yellow annotations */ rd-annotation[data-annotation-color="yellow"] { - background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)"); + background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="yellow"].selected { - background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.6)" : "rgba(107, 79, 3, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(158, 117, 4, 0.5)" : "rgba(107, 79, 3, 0.6)"); + background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6)); } /* Green annotations */ rd-annotation[data-annotation-color="green"] { - background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)"); + background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="green"].selected { - background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.6)" : "rgba(57, 88, 9, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(132, 204, 22, 0.5)" : "rgba(57, 88, 9, 0.6)"); + background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6)); } /* Blue annotations */ rd-annotation[data-annotation-color="blue"] { - background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)"); + background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="blue"].selected { - background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.6)" : "rgba(7, 95, 116, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(9, 132, 159, 0.5)" : "rgba(7, 95, 116, 0.6)"); + background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6)); } /* Red annotations */ rd-annotation[data-annotation-color="red"] { - background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)"); + background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="red"].selected { - background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.6)" : "rgba(103, 29, 29, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(152, 43, 43, 0.5)" : "rgba(103, 29, 29, 0.6)"); + background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6)); } @@ -313,30 +314,11 @@ struct WebView: UIViewRepresentable { img.addEventListener('load', debouncedHeightUpdate); }); - // Text selection detection - document.addEventListener('selectionchange', function() { - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - const range = selection.getRangeAt(0); - const selectedText = selection.toString(); - - // Calculate character offset from start of body - const preRange = document.createRange(); - preRange.selectNodeContents(document.body); - preRange.setEnd(range.startContainer, range.startOffset); - const startOffset = preRange.toString().length; - const endOffset = startOffset + selectedText.length; - - window.webkit.messageHandlers.textSelected.postMessage({ - text: selectedText, - startOffset: startOffset, - endOffset: endOffset - }); - } - }); - // Scroll to selected annotation \(generateScrollToAnnotationJS()) + + // Text Selection and Annotation Overlay + \(generateAnnotationOverlayJS(isDarkMode: isDarkMode)) @@ -349,6 +331,7 @@ struct WebView: UIViewRepresentable { webView.navigationDelegate = nil webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate") webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress") + webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated") webView.loadHTMLString("", baseURL: nil) coordinator.cleanup() } @@ -409,13 +392,264 @@ struct WebView: UIViewRepresentable { } """ } + + private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String { + let yellowColor = AnnotationColor.yellow.cssColor(isDark: isDarkMode) + let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode) + let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode) + let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode) + + return """ + // Create annotation color overlay + (function() { + let currentSelection = null; + let currentRange = null; + let selectionTimeout = null; + + // Create overlay container with arrow + const overlay = document.createElement('div'); + overlay.id = 'annotation-overlay'; + overlay.style.cssText = ` + display: none; + position: absolute; + z-index: 10000; + `; + + // Create arrow/triangle pointing up with glass effect + const arrow = document.createElement('div'); + arrow.style.cssText = ` + position: absolute; + width: 20px; + height: 20px; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-right: none; + border-bottom: none; + top: -11px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + `; + overlay.appendChild(arrow); + + // Create the actual content container with glass morphism effect + const content = document.createElement('div'); + content.style.cssText = ` + display: flex; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 24px; + padding: 12px 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), + 0 2px 8px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.3); + gap: 12px; + flex-direction: row; + align-items: center; + `; + overlay.appendChild(content); + + // Add "Markierung" label + const label = document.createElement('span'); + label.textContent = 'Markierung'; + label.style.cssText = ` + color: black; + font-size: 16px; + font-weight: 500; + margin-right: 4px; + `; + content.appendChild(label); + + // Create color buttons with solid colors + const colors = [ + { name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' }, + { name: 'red', color: '\(AnnotationColor.red.hexColor)' }, + { name: 'blue', color: '\(AnnotationColor.blue.hexColor)' }, + { name: 'green', color: '\(AnnotationColor.green.hexColor)' } + ]; + + colors.forEach(({ name, color }) => { + const btn = document.createElement('button'); + btn.dataset.color = name; + btn.style.cssText = ` + width: 40px; + height: 40px; + border-radius: 50%; + background: ${color}; + border: 3px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 0; + margin: 0; + transition: transform 0.2s, border-color 0.2s; + `; + btn.addEventListener('mouseenter', () => { + btn.style.transform = 'scale(1.1)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.6)'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.transform = 'scale(1)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.3)'; + }); + btn.addEventListener('click', () => handleColorSelection(name)); + content.appendChild(btn); + }); + + document.body.appendChild(overlay); + + // Selection change listener + document.addEventListener('selectionchange', () => { + clearTimeout(selectionTimeout); + selectionTimeout = setTimeout(() => { + const selection = window.getSelection(); + const text = selection.toString().trim(); + + if (text.length > 0) { + currentSelection = text; + currentRange = selection.getRangeAt(0).cloneRange(); + showOverlay(selection.getRangeAt(0)); + } else { + hideOverlay(); + } + }, 150); + }); + + function showOverlay(range) { + const rect = range.getBoundingClientRect(); + const scrollY = window.scrollY || window.pageYOffset; + + overlay.style.display = 'block'; + + // Center horizontally under selection + const overlayWidth = 320; // Approximate width with label + 4 buttons + const centerX = rect.left + (rect.width / 2); + const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8)); + + // Position with extra space below selection (55px instead of 70px) to bring it closer + const topPos = rect.bottom + scrollY + 55; + + overlay.style.left = leftPos + 'px'; + overlay.style.top = topPos + 'px'; + } + + function hideOverlay() { + overlay.style.display = 'none'; + currentSelection = null; + currentRange = null; + } + + function calculateOffset(container, offset) { + const preRange = document.createRange(); + preRange.selectNodeContents(document.body); + preRange.setEnd(container, offset); + return preRange.toString().length; + } + + function getXPathSelector(node) { + // If node is text node, use parent element + const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + if (!element || element === document.body) return 'body'; + + const path = []; + let current = element; + + while (current && current !== document.body) { + const tagName = current.tagName.toLowerCase(); + + // Count position among siblings of same tag (1-based index) + let index = 1; + let sibling = current.previousElementSibling; + while (sibling) { + if (sibling.tagName === current.tagName) { + index++; + } + sibling = sibling.previousElementSibling; + } + + // Format: tagname[index] (1-based) + path.unshift(tagName + '[' + index + ']'); + + current = current.parentElement; + } + + const selector = path.join('/'); + console.log('Generated selector:', selector); + return selector || 'body'; + } + + function calculateOffsetInElement(container, offset) { + // Calculate offset relative to the parent element (not document.body) + const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container; + if (!element) return offset; + + // Create range from start of element to the position + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(container, offset); + + return range.toString().length; + } + + function generateTempId() { + return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); + } + + function handleColorSelection(color) { + if (!currentRange || !currentSelection) return; + + // Generate XPath-like selectors for start and end containers + const startSelector = getXPathSelector(currentRange.startContainer); + const endSelector = getXPathSelector(currentRange.endContainer); + + // Calculate offsets relative to the element (not document.body) + const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset); + const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset); + + // Create annotation element + const annotation = document.createElement('rd-annotation'); + annotation.setAttribute('data-annotation-color', color); + annotation.setAttribute('data-annotation-id-value', generateTempId()); + + // Wrap selection in annotation + try { + currentRange.surroundContents(annotation); + } catch (e) { + // If surroundContents fails (e.g., partial element selection), extract and wrap + const fragment = currentRange.extractContents(); + annotation.appendChild(fragment); + currentRange.insertNode(annotation); + } + + // Send to Swift with selectors + window.webkit.messageHandlers.annotationCreated.postMessage({ + color: color, + text: currentSelection, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + }); + + // Clear selection and hide overlay + window.getSelection().removeAllRanges(); + hideOverlay(); + } + })(); + """ + } } class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { // Callbacks var onHeightChange: ((CGFloat) -> Void)? var onScroll: ((Double) -> Void)? - var onTextSelected: ((String, Int, Int) -> Void)? + var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? + + // WebView reference + weak var webView: WKWebView? // Height management var lastHeight: CGFloat = 0 @@ -457,12 +691,15 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler self.handleScrollProgress(progress: progress) } } - if message.name == "textSelected", let body = message.body as? [String: Any], + if message.name == "annotationCreated", let body = message.body as? [String: Any], + let color = body["color"] as? String, let text = body["text"] as? String, let startOffset = body["startOffset"] as? Int, - let endOffset = body["endOffset"] as? Int { + let endOffset = body["endOffset"] as? Int, + let startSelector = body["startSelector"] as? String, + let endSelector = body["endSelector"] as? String { DispatchQueue.main.async { - self.onTextSelected?(text, startOffset, endOffset) + self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector) } } } @@ -532,13 +769,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler func cleanup() { guard !isCleanedUp else { return } isCleanedUp = true - + scrollEndTimer?.invalidate() scrollEndTimer = nil heightUpdateTimer?.invalidate() heightUpdateTimer = nil - + onHeightChange = nil onScroll = nil + onAnnotationCreated = nil } } From c629894611563518f34338e859e9d38469a1321d Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 26 Oct 2025 21:19:27 +0100 Subject: [PATCH 21/39] feat: Show annotations button only when article contains annotations Add conditional visibility for the annotations button in the toolbar based on whether the loaded article contains any rd-annotation tags. Changes: - Add hasAnnotations property to BookmarkDetailViewModel - Check for () @@ -85,8 +86,11 @@ class BookmarkDetailViewModel { let paragraphs = articleContent .components(separatedBy: .newlines) .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - + articleParagraphs = paragraphs + + // Check if article contains annotations + hasAnnotations = articleContent.contains(" Date: Sun, 26 Oct 2025 21:22:15 +0100 Subject: [PATCH 22/39] perf: Optimize label loading for 1000+ labels Major performance improvements to prevent crashes and lag when working with large label collections: Main App: - Switch to Core Data as primary source for labels (instant loading) - Implement background API sync to keep labels up-to-date - Add LazyVStack for efficient rendering of large label lists - Use batch operations instead of individual queries (1 query vs 1000) - Generate unique IDs for local labels to prevent duplicate warnings Share Extension: - Convert getTags() to async with background context - Add saveTags() method with batch insert support - Load labels from Core Data first, then sync with API - Remove duplicate server reachability checks - Reduce memory usage and prevent UI freezes Technical Details: - Labels now load instantly from local cache - API sync happens in background without blocking UI - Batch fetch operations for optimal database performance - Proper error handling for offline scenarios - Fixed duplicate ID warnings in ForEach loops Fixes crashes and lag reported by users with 1000+ labels. --- URLShare/OfflineBookmarkManager.swift | 47 ++++++++++-- URLShare/ShareBookmarkViewModel.swift | 50 ++++++------- .../Data/Repository/LabelsRepository.swift | 72 +++++++++++++------ readeck/Resources/RELEASE_NOTES.md | 33 +++++++++ readeck/UI/Components/TagManagementView.swift | 2 +- 5 files changed, 152 insertions(+), 52 deletions(-) diff --git a/URLShare/OfflineBookmarkManager.swift b/URLShare/OfflineBookmarkManager.swift index 6b317b9..28eb785 100644 --- a/URLShare/OfflineBookmarkManager.swift +++ b/URLShare/OfflineBookmarkManager.swift @@ -49,19 +49,54 @@ class OfflineBookmarkManager: @unchecked Sendable { } } - func getTags() -> [String] { + func getTags() async -> [String] { + let backgroundContext = CoreDataManager.shared.newBackgroundContext() + do { - return try context.safePerform { [weak self] in - guard let self = self else { return [] } - + return try await backgroundContext.perform { let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() - let tagEntities = try self.context.fetch(fetchRequest) - return tagEntities.compactMap { $0.name }.sorted() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + + let tagEntities = try backgroundContext.fetch(fetchRequest) + return tagEntities.compactMap { $0.name } } } catch { print("Failed to fetch tags: \(error)") return [] } } + + func saveTags(_ tags: [String]) async { + let backgroundContext = CoreDataManager.shared.newBackgroundContext() + + do { + try await backgroundContext.perform { + // Batch fetch existing tags + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.propertiesToFetch = ["name"] + + let existingEntities = try backgroundContext.fetch(fetchRequest) + let existingNames = Set(existingEntities.compactMap { $0.name }) + + // Only insert new tags + var insertCount = 0 + for tag in tags { + if !existingNames.contains(tag) { + let entity = TagEntity(context: backgroundContext) + entity.name = tag + insertCount += 1 + } + } + + // Only save if there are new tags + if insertCount > 0 { + try backgroundContext.save() + print("Saved \(insertCount) new tags to Core Data") + } + } + } catch { + print("Failed to save tags: \(error)") + } + } } diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index 2e65612..d01a9a8 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -51,22 +51,9 @@ class ShareBookmarkViewModel: ObservableObject { func onAppear() { logger.debug("ShareBookmarkViewModel appeared") - checkServerReachability() loadLabels() } - private func checkServerReachability() { - let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger) - Task { - let reachable = await serverCheck.checkServerReachability() - await MainActor.run { - self.isServerReachable = reachable - logger.info("Server reachability checked: \(reachable)") - measurement.end() - } - } - } - private func extractSharedContent() { logger.debug("Starting to extract shared content") guard let extensionContext = extensionContext else { @@ -137,30 +124,43 @@ class ShareBookmarkViewModel: ObservableObject { let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger) logger.debug("Starting to load labels") Task { + // 1. First, load from Core Data (instant response) + let localTags = await OfflineBookmarkManager.shared.getTags() + let localLabels = localTags.enumerated().map { index, tagName in + BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)") + } + + await MainActor.run { + self.labels = localLabels + self.logger.info("Loaded \(localLabels.count) labels from local cache") + } + + // 2. Then check server and sync in background let serverReachable = await serverCheck.checkServerReachability() + await MainActor.run { + self.isServerReachable = serverReachable + } logger.debug("Server reachable for labels: \(serverReachable)") if serverReachable { let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in - self?.statusMessage = (message, error, error ? "❌" : "βœ…") + if error { + self?.logger.error("Failed to sync labels from API: \(message)") + } } ?? [] + + // Save new labels to Core Data + let tagNames = loaded.map { $0.name } + await OfflineBookmarkManager.shared.saveTags(tagNames) + let sorted = loaded.sorted { $0.count > $1.count } await MainActor.run { self.labels = Array(sorted) - self.logger.info("Loaded \(loaded.count) labels from API") + self.logger.info("Synced \(loaded.count) labels from API and updated cache") measurement.end() } } else { - let localTags = OfflineBookmarkManager.shared.getTags() - let localLabels = localTags.enumerated().map { index, tagName in - BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)") - } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - await MainActor.run { - self.labels = localLabels - self.logger.info("Loaded \(localLabels.count) labels from local database") - measurement.end() - } + measurement.end() } } } diff --git a/readeck/Data/Repository/LabelsRepository.swift b/readeck/Data/Repository/LabelsRepository.swift index bb73e26..08e8427 100644 --- a/readeck/Data/Repository/LabelsRepository.swift +++ b/readeck/Data/Repository/LabelsRepository.swift @@ -11,34 +11,66 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable { } func getLabels() async throws -> [BookmarkLabel] { - let dtos = try await api.getBookmarkLabels() - try? await saveLabels(dtos) - return dtos.map { $0.toDomain() } + // First, load from Core Data (instant response) + let cachedLabels = try await loadLabelsFromCoreData() + + // Then sync with API in background (don't wait) + Task.detached(priority: .background) { [weak self] in + guard let self = self else { return } + do { + let dtos = try await self.api.getBookmarkLabels() + try? await self.saveLabels(dtos) + } catch { + // Silent fail - we already have cached data + } + } + + return cachedLabels + } + + private func loadLabelsFromCoreData() async throws -> [BookmarkLabel] { + let backgroundContext = coreDataManager.newBackgroundContext() + + return try await backgroundContext.perform { + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + + let entities = try backgroundContext.fetch(fetchRequest) + return entities.compactMap { entity -> BookmarkLabel? in + guard let name = entity.name, !name.isEmpty else { return nil } + return BookmarkLabel( + name: name, + count: 0, + href: name + ) + } + } } func saveLabels(_ dtos: [BookmarkLabelDto]) async throws { let backgroundContext = coreDataManager.newBackgroundContext() - - try await backgroundContext.perform { [weak self] in - guard let self = self else { return } + + try await backgroundContext.perform { + // Batch fetch all existing label names (much faster than individual queries) + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.propertiesToFetch = ["name"] + + let existingEntities = try backgroundContext.fetch(fetchRequest) + let existingNames = Set(existingEntities.compactMap { $0.name }) + + // Only insert new labels + var insertCount = 0 for dto in dtos { - if !self.tagExists(name: dto.name, in: backgroundContext) { + if !existingNames.contains(dto.name) { dto.toEntity(context: backgroundContext) + insertCount += 1 } } - try backgroundContext.save() - } - } - - private func tagExists(name: String, in context: NSManagedObjectContext) -> Bool { - let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "name == %@", name) - - do { - let count = try context.count(for: fetchRequest) - return count > 0 - } catch { - return false + + // Only save if there are new labels + if insertCount > 0 { + try backgroundContext.save() + } } } } diff --git a/readeck/Resources/RELEASE_NOTES.md b/readeck/Resources/RELEASE_NOTES.md index f4dfada..9c2b081 100644 --- a/readeck/Resources/RELEASE_NOTES.md +++ b/readeck/Resources/RELEASE_NOTES.md @@ -4,6 +4,39 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi **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 + +### 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 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. diff --git a/readeck/UI/Components/TagManagementView.swift b/readeck/UI/Components/TagManagementView.swift index b6cb1e4..a680faa 100644 --- a/readeck/UI/Components/TagManagementView.swift +++ b/readeck/UI/Components/TagManagementView.swift @@ -214,7 +214,7 @@ struct TagManagementView: View { @ViewBuilder private var labelsScrollView: some View { ScrollView(.horizontal, showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { + LazyVStack(alignment: .leading, spacing: 8) { ForEach(chunkedLabels, id: \.self) { rowLabels in HStack(alignment: .top, spacing: 8) { ForEach(rowLabels, id: \.id) { label in From 87464943ac859cc421df52b4710022bb97243430 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 29 Oct 2025 22:12:02 +0100 Subject: [PATCH 23/39] bumped build version and version --- readeck.xcodeproj/project.pbxproj | 16 ++++++++-------- readeck/Resources/RELEASE_NOTES.md | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index f112edb..d544772 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -437,7 +437,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -450,7 +450,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -470,7 +470,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -483,7 +483,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -625,7 +625,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -648,7 +648,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -669,7 +669,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -692,7 +692,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/readeck/Resources/RELEASE_NOTES.md b/readeck/Resources/RELEASE_NOTES.md index 9c2b081..bbc938a 100644 --- a/readeck/Resources/RELEASE_NOTES.md +++ b/readeck/Resources/RELEASE_NOTES.md @@ -4,7 +4,7 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi **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 +## Version 1.2.0 ### Annotations & Highlighting @@ -37,7 +37,7 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi --- -## Version 1.1 +## 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. From cdfa6dc4c54ff5c1b12bab104102dd8923e38592 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Thu, 30 Oct 2025 21:07:13 +0100 Subject: [PATCH 24/39] 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. --- .../BookmarkDetailLegacyView.swift | 12 +- .../BookmarkDetail/BookmarkDetailView2.swift | 10 + readeck/UI/Components/NativeWebView.swift | 43 ++- readeck/UI/Components/WebView.swift | 21 +- readeck/UI/Resources/RELEASE_NOTES.md | 120 ++++++++ readeck/Utils/Logger.swift | 262 ++++++++++++++++++ 6 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 readeck/UI/Resources/RELEASE_NOTES.md create mode 100644 readeck/Utils/Logger.swift diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift index b44becd..ef242ef 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -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) diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index f43cad4..833c22c 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -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) diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift index 352c110..ca5180b 100644 --- a/readeck/UI/Components/NativeWebView.swift +++ b/readeck/UI/Components/NativeWebView.swift @@ -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() @@ -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 { 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); } } diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index 442f5b1..0be285a 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -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 } } diff --git a/readeck/UI/Resources/RELEASE_NOTES.md b/readeck/UI/Resources/RELEASE_NOTES.md new file mode 100644 index 0000000..bbc938a --- /dev/null +++ b/readeck/UI/Resources/RELEASE_NOTES.md @@ -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 + + diff --git a/readeck/Utils/Logger.swift b/readeck/Utils/Logger.swift new file mode 100644 index 0000000..cc8f6c1 --- /dev/null +++ b/readeck/Utils/Logger.swift @@ -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(_ transform: (Key) throws -> T) rethrows -> [T: Value] { + return try Dictionary(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 + } +} + From db3cbf41d4c8ddead92df4ec615f085d02382fea Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Thu, 30 Oct 2025 21:14:40 +0100 Subject: [PATCH 25/39] Fix URL label translation and formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add localization keys for "open_url" and "open_original_page" in EN/DE - Create URLUtil.openUrlLabel() helper function for consistent formatting - Replace incorrect string concatenation with proper localized labels - Fix: "example.comopen" now displays as "Open example.com" (EN) or "example.com ΓΆffnen" (DE) - Update BookmarkDetailLegacyView, BookmarkDetailView2, and BookmarkCardView --- readeck/Localizations/de.lproj/Localizable.strings | 2 ++ readeck/Localizations/en.lproj/Localizable.strings | 2 ++ readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift | 6 +++--- readeck/UI/BookmarkDetail/BookmarkDetailView2.swift | 4 ++-- readeck/UI/Bookmarks/BookmarkCardView.swift | 4 ++-- readeck/UI/Utils/URLUtil.swift | 8 ++++++++ 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/readeck/Localizations/de.lproj/Localizable.strings b/readeck/Localizations/de.lproj/Localizable.strings index 25a4a98..7120e61 100644 --- a/readeck/Localizations/de.lproj/Localizable.strings +++ b/readeck/Localizations/de.lproj/Localizable.strings @@ -105,6 +105,8 @@ "More" = "Mehr"; "New Bookmark" = "Neues Lesezeichen"; "No articles in the queue" = "Keine Artikel in der Warteschlange"; +"open_url" = "%@ ΓΆffnen"; +"open_original_page" = "Originalseite ΓΆffnen"; "No bookmarks" = "Keine Lesezeichen"; "No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden."; "No bookmarks found." = "Keine Lesezeichen gefunden."; diff --git a/readeck/Localizations/en.lproj/Localizable.strings b/readeck/Localizations/en.lproj/Localizable.strings index bf5bb2a..dfe7fca 100644 --- a/readeck/Localizations/en.lproj/Localizable.strings +++ b/readeck/Localizations/en.lproj/Localizable.strings @@ -101,6 +101,8 @@ "More" = "More"; "New Bookmark" = "New Bookmark"; "No articles in the queue" = "No articles in the queue"; +"open_url" = "Open %@"; +"open_original_page" = "Open original page"; "No bookmarks" = "No bookmarks"; "No bookmarks found in %@." = "No bookmarks found in %@."; "No bookmarks found." = "No bookmarks found."; diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift index ef242ef..334bf83 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -126,7 +126,7 @@ struct BookmarkDetailLegacyView: View { }) { HStack { Image(systemName: "safari") - Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page")) + Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) } .font(.title3.bold()) .frame(maxWidth: .infinity) @@ -402,7 +402,7 @@ struct BookmarkDetailLegacyView: View { }) { HStack { Image(systemName: "safari") - Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + "open") + Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) } .font(.title3.bold()) .frame(maxWidth: .infinity) @@ -456,7 +456,7 @@ struct BookmarkDetailLegacyView: View { Button(action: { URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) }) { - Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open") + Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) .font(.subheadline) .foregroundColor(.secondary) } diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index 833c22c..a3eae94 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -411,7 +411,7 @@ struct BookmarkDetailView2: View { Button(action: { URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) }) { - Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open") + Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) .font(.subheadline) .foregroundColor(.secondary) } @@ -501,7 +501,7 @@ struct BookmarkDetailView2: View { }) { HStack { Image(systemName: "safari") - Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page")) + Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) } .font(.title3.bold()) .frame(maxWidth: .infinity) diff --git a/readeck/UI/Bookmarks/BookmarkCardView.swift b/readeck/UI/Bookmarks/BookmarkCardView.swift index 32a2080..7c2303f 100644 --- a/readeck/UI/Bookmarks/BookmarkCardView.swift +++ b/readeck/UI/Bookmarks/BookmarkCardView.swift @@ -254,7 +254,7 @@ struct BookmarkCardView: View { } } HStack { - Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari") + Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari") .onTapGesture { URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener) } @@ -335,7 +335,7 @@ struct BookmarkCardView: View { } } HStack { - Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari") + Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari") .onTapGesture { URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener) } diff --git a/readeck/UI/Utils/URLUtil.swift b/readeck/UI/Utils/URLUtil.swift index 7a9b9a7..b8f6159 100644 --- a/readeck/UI/Utils/URLUtil.swift +++ b/readeck/UI/Utils/URLUtil.swift @@ -44,4 +44,12 @@ struct URLUtil { guard let url = URL(string: urlString), let host = url.host else { return nil } return host.replacingOccurrences(of: "www.", with: "") } + + static func openUrlLabel(for urlString: String) -> String { + if let domain = extractDomain(from: urlString) { + return String(format: "open_url".localized, domain) + } else { + return "open_original_page".localized + } + } } From 85bad35788f07691b189a9e24e722daafd9cffd8 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Thu, 30 Oct 2025 21:48:28 +0100 Subject: [PATCH 26/39] Refactor release notes to use MarkdownUI library - Create MarkdownContentView to encapsulate MarkdownUI rendering - Replace custom AttributedString markdown parsing with MarkdownUI - Simplify ReleaseNotesView by removing manual markdown styling - Improve markdown rendering with proper support for lists, links, and formatting - Make markdown rendering easily replaceable by keeping it in a dedicated view --- readeck/UI/Settings/MarkdownContentView.swift | 35 ++++++++++++ readeck/UI/Settings/ReleaseNotesView.swift | 55 ++----------------- 2 files changed, 41 insertions(+), 49 deletions(-) create mode 100644 readeck/UI/Settings/MarkdownContentView.swift diff --git a/readeck/UI/Settings/MarkdownContentView.swift b/readeck/UI/Settings/MarkdownContentView.swift new file mode 100644 index 0000000..d105eb4 --- /dev/null +++ b/readeck/UI/Settings/MarkdownContentView.swift @@ -0,0 +1,35 @@ +import SwiftUI +import MarkdownUI + +/// A custom view that renders Markdown content using the MarkdownUI library. +/// This view encapsulates the Markdown rendering logic, making it easy to swap +/// the underlying Markdown library if needed in the future. +struct MarkdownContentView: View { + let content: String + + var body: some View { + Markdown(content) + .textSelection(.enabled) + } +} + +#Preview { + ScrollView { + MarkdownContentView(content: """ +# Heading 1 + +This is a paragraph with **bold** and *italic* text. + +## Heading 2 + +- List item 1 +- List item 2 +- List item 3 + +### Heading 3 + +Another paragraph with [a link](https://example.com). +""") + .padding() + } +} diff --git a/readeck/UI/Settings/ReleaseNotesView.swift b/readeck/UI/Settings/ReleaseNotesView.swift index 76724af..d91999b 100644 --- a/readeck/UI/Settings/ReleaseNotesView.swift +++ b/readeck/UI/Settings/ReleaseNotesView.swift @@ -1,56 +1,14 @@ import SwiftUI -extension AttributedString { - init(styledMarkdown markdownString: String) throws { - var output = try AttributedString( - markdown: markdownString, - options: .init( - allowsExtendedAttributes: true, - interpretedSyntax: .full, - failurePolicy: .returnPartiallyParsedIfPossible - ), - baseURL: nil - ) - - for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() { - guard let intentBlock = intentBlock else { continue } - for intent in intentBlock.components { - switch intent.kind { - case .header(level: let level): - switch level { - case 1: - output[intentRange].font = .system(.title).bold() - case 2: - output[intentRange].font = .system(.title2).bold() - case 3: - output[intentRange].font = .system(.title3).bold() - default: - break - } - default: - break - } - } - - if intentRange.lowerBound != output.startIndex { - output.characters.insert(contentsOf: "\n\n", at: intentRange.lowerBound) - } - } - - self = output - } -} - struct ReleaseNotesView: View { @Environment(\.dismiss) private var dismiss var body: some View { NavigationView { ScrollView { - VStack(alignment: .leading, spacing: 16) { - if let attributedString = loadReleaseNotes() { - Text(attributedString) - .textSelection(.enabled) + VStack(alignment: .leading, spacing: 0) { + if let markdownContent = loadReleaseNotes() { + MarkdownContentView(content: markdownContent) .padding() } else { Text("Unable to load release notes") @@ -71,13 +29,12 @@ struct ReleaseNotesView: View { } } - private func loadReleaseNotes() -> AttributedString? { + private func loadReleaseNotes() -> String? { guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"), - let markdownContent = try? String(contentsOf: url), - let attributedString = try? AttributedString(styledMarkdown: markdownContent) else { + let markdownContent = try? String(contentsOf: url) else { return nil } - return attributedString + return markdownContent } } From 05ae421a40f6e13a456e75530455c54ca54d01c3 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Thu, 30 Oct 2025 21:50:33 +0100 Subject: [PATCH 27/39] Add MarkdownUI package and cleanup project structure - Add swift-markdown-ui package dependency (v2.4.1) - Remove old Logger.swift (moved to Utils/Logger.swift) - Remove RELEASE_NOTES.md from Resources (moved to UI/Resources) - Update German localization strings for settings sections - Bump build version to 32 --- readeck.xcodeproj/project.pbxproj | 27 +- .../xcshareddata/swiftpm/Package.resolved | 29 +- .../de.lproj/Localizable.strings | 14 +- readeck/Logger.swift | 262 ------------------ readeck/Resources/RELEASE_NOTES.md | 120 -------- 5 files changed, 57 insertions(+), 395 deletions(-) delete mode 100644 readeck/Logger.swift delete mode 100644 readeck/Resources/RELEASE_NOTES.md diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index d544772..9b5f902 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; }; + 5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5D48E6012EB402F50043F90F /* MarkdownUI */; }; 5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; }; 5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; }; /* End PBXBuildFile section */ @@ -86,7 +87,6 @@ Data/Utils/LabelUtils.swift, Domain/Model/Bookmark.swift, Domain/Model/BookmarkLabel.swift, - Logger.swift, readeck.xcdatamodeld, Splash.storyboard, UI/Components/Constants.swift, @@ -94,6 +94,7 @@ UI/Components/TagManagementView.swift, UI/Components/UnifiedLabelChip.swift, UI/Utils/NotificationNames.swift, + Utils/Logger.swift, ); target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */; }; @@ -151,6 +152,7 @@ 5D9D95492E623668009AF769 /* Kingfisher in Frameworks */, 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */, 5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */, + 5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -242,6 +244,7 @@ 5D348CC22E0C9F4F00D0AF21 /* netfox */, 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */, 5D9D95482E623668009AF769 /* Kingfisher */, + 5D48E6012EB402F50043F90F /* MarkdownUI */, ); productName = readeck; productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */; @@ -333,6 +336,7 @@ 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */, 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */, 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */, + 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, ); preferredProjectObjectVersion = 77; productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */; @@ -437,7 +441,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -470,7 +474,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -625,7 +629,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -669,7 +673,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -854,6 +858,14 @@ minimumVersion = 1.21.0; }; }; + 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; + }; + }; 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/onevcat/Kingfisher.git"; @@ -878,6 +890,11 @@ package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */; productName = netfox; }; + 5D48E6012EB402F50043F90F /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; 5D9D95482E623668009AF769 /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */; diff --git a/readeck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/readeck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 671df69..f42a4fd 100644 --- a/readeck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/readeck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457", + "originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635", "pins" : [ { "identity" : "kingfisher", @@ -19,6 +19,15 @@ "version" : "1.21.0" } }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, { "identity" : "r.swift", "kind" : "remoteSourceControl", @@ -37,6 +46,24 @@ "version" : "1.6.1" } }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, { "identity" : "xcodeedit", "kind" : "remoteSourceControl", diff --git a/readeck/Localizations/de.lproj/Localizable.strings b/readeck/Localizations/de.lproj/Localizable.strings index 7120e61..f2253a1 100644 --- a/readeck/Localizations/de.lproj/Localizable.strings +++ b/readeck/Localizations/de.lproj/Localizable.strings @@ -40,11 +40,11 @@ "Tags" = "Labels"; /* Settings Sections */ -"Font Settings" = "Schriftart-Einstellungen"; +"Font Settings" = "Schriftart"; "Appearance" = "Darstellung"; -"Cache Settings" = "Cache-Einstellungen"; -"General Settings" = "Allgemeine Einstellungen"; -"Server Settings" = "Server-Einstellungen"; +"Cache Settings" = "Cache"; +"General Settings" = "Allgemein"; +"Server Settings" = "Server"; "Server Connection" = "Server-Verbindung"; "Open external links in" = "Γ–ffne externe Links in"; "In App Browser" = "In App Browser"; @@ -67,7 +67,7 @@ "Critical" = "Kritisch"; "Debug" = "Debug"; "DEBUG BUILD" = "DEBUG BUILD"; -"Debug Settings" = "Debug-Einstellungen"; +"Debug Settings" = "Debug"; "Delete" = "LΓΆschen"; "Delete Bookmark" = "Lesezeichen lΓΆschen"; "Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak"; @@ -80,13 +80,13 @@ "Finished reading?" = "Fertig gelesen?"; "Font" = "Schrift"; "Font family" = "Schriftart"; -"Font Settings" = "Schrift-Einstellungen"; +"Font Settings" = "Schrift"; "Font size" = "Schriftgrâße"; "From Bremen with πŸ’š" = "Aus Bremen mit πŸ’š"; "General" = "Allgemein"; "Global Level" = "Globales Level"; "Global Minimum Level" = "Globales Minimum-Level"; -"Global Settings" = "Globale Einstellungen"; +"Global Settings" = "Global"; "https://example.com" = "https://example.com"; "https://readeck.example.com" = "https://readeck.example.com"; "Include Source Location" = "Quellort einschließen"; diff --git a/readeck/Logger.swift b/readeck/Logger.swift deleted file mode 100644 index cc8f6c1..0000000 --- a/readeck/Logger.swift +++ /dev/null @@ -1,262 +0,0 @@ -// -// 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(_ transform: (Key) throws -> T) rethrows -> [T: Value] { - return try Dictionary(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 - } -} - diff --git a/readeck/Resources/RELEASE_NOTES.md b/readeck/Resources/RELEASE_NOTES.md deleted file mode 100644 index bbc938a..0000000 --- a/readeck/Resources/RELEASE_NOTES.md +++ /dev/null @@ -1,120 +0,0 @@ -# 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 - - From 571d61c8e5173f73dce355df723be453b740e451 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Thu, 30 Oct 2025 21:54:02 +0100 Subject: [PATCH 28/39] Update release notes for markdown rendering improvement --- readeck/UI/Resources/RELEASE_NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readeck/UI/Resources/RELEASE_NOTES.md b/readeck/UI/Resources/RELEASE_NOTES.md index bbc938a..54a9210 100644 --- a/readeck/UI/Resources/RELEASE_NOTES.md +++ b/readeck/UI/Resources/RELEASE_NOTES.md @@ -34,6 +34,7 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi - 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 +- Improved markdown rendering in release notes with better formatting support --- From 4e973751f45ccdf33d96d1f32dbacbfb55ece767 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Thu, 30 Oct 2025 22:27:14 +0100 Subject: [PATCH 29/39] Simplify release notes for regular users --- readeck/UI/Resources/RELEASE_NOTES.md | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/readeck/UI/Resources/RELEASE_NOTES.md b/readeck/UI/Resources/RELEASE_NOTES.md index 54a9210..559c38f 100644 --- a/readeck/UI/Resources/RELEASE_NOTES.md +++ b/readeck/UI/Resources/RELEASE_NOTES.md @@ -18,23 +18,17 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi ### 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 +- Labels now load instantly, even without internet connection +- Share Extension loads much faster +- Better performance when working with many labels +- Improved overall app stability ### 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 -- Improved markdown rendering in release notes with better formatting support +- Better color consistency throughout the app +- Improved text selection in articles +- Better formatted release notes +- Various bug fixes and stability improvements --- From 589fcdb2b4659fcb21afafbfa36d335a00e67d93 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Thu, 30 Oct 2025 22:39:38 +0100 Subject: [PATCH 30/39] Bump build version to 33 --- readeck.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 9b5f902..1f7387a 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -441,7 +441,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -474,7 +474,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -629,7 +629,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -673,7 +673,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; From 4b93c605f1896bf360d34da44300c35dbe0519f3 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Fri, 31 Oct 2025 23:39:59 +0100 Subject: [PATCH 31/39] Redesign settings screen with native iOS style - Refactor all settings views to use List with .insetGrouped style - Create reusable SettingsRow components for consistent UI - Separate onboarding flow into dedicated OnboardingServerView - Consolidate font, theme, and card layout into unified Appearance section - Add visual card layout previews in dedicated selection screen - Move "Open links in" option to Appearance with descriptive footer - Improve settings organization and native iOS feel --- readeck/UI/Components/SettingsRow.swift | 308 ++++++++++++++++++ .../UI/Onboarding/OnboardingServerView.swift | 175 ++++++++++ .../UI/Settings/AppearanceSettingsView.swift | 268 ++++++--------- readeck/UI/Settings/CacheSettingsView.swift | 147 ++++----- .../UI/Settings/CardLayoutSelectionView.swift | 171 ++++++++++ readeck/UI/Settings/FontSettingsView.swift | 101 +++--- .../Settings/LegalPrivacySettingsView.swift | 122 +++---- .../UI/Settings/SettingsContainerView.swift | 163 ++++----- readeck/UI/Settings/SettingsGeneralView.swift | 100 ++---- readeck/UI/Settings/SettingsServerView.swift | 223 ++----------- readeck/UI/readeckApp.swift | 2 +- 11 files changed, 1031 insertions(+), 749 deletions(-) create mode 100644 readeck/UI/Components/SettingsRow.swift create mode 100644 readeck/UI/Onboarding/OnboardingServerView.swift create mode 100644 readeck/UI/Settings/CardLayoutSelectionView.swift diff --git a/readeck/UI/Components/SettingsRow.swift b/readeck/UI/Components/SettingsRow.swift new file mode 100644 index 0000000..5c7f903 --- /dev/null +++ b/readeck/UI/Components/SettingsRow.swift @@ -0,0 +1,308 @@ +// +// SettingsRow.swift +// readeck +// +// Created by Ilyas Hallak on 31.10.25. +// + +import SwiftUI + +// MARK: - Settings Row with Navigation Link +struct SettingsRowNavigationLink: View { + let icon: String? + let iconColor: Color + let title: String + let subtitle: String? + let destination: Destination + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + subtitle: String? = nil, + @ViewBuilder destination: () -> Destination + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.subtitle = subtitle + self.destination = destination() + } + + var body: some View { + NavigationLink(destination: destination) { + SettingsRowLabel( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: subtitle + ) + } + } +} + +// MARK: - Settings Row with Toggle +struct SettingsRowToggle: View { + let icon: String? + let iconColor: Color + let title: String + let subtitle: String? + @Binding var isOn: Bool + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + subtitle: String? = nil, + isOn: Binding + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.subtitle = subtitle + self._isOn = isOn + } + + var body: some View { + HStack { + SettingsRowLabel( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: subtitle + ) + Toggle("", isOn: $isOn) + .labelsHidden() + } + } +} + +// MARK: - Settings Row with Value Display +struct SettingsRowValue: View { + let icon: String? + let iconColor: Color + let title: String + let value: String + let valueColor: Color + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + value: String, + valueColor: Color = .secondary + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.value = value + self.valueColor = valueColor + } + + var body: some View { + HStack { + SettingsRowLabel( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: nil + ) + Spacer() + Text(value) + .foregroundColor(valueColor) + } + } +} + +// MARK: - Settings Row Button (for actions) +struct SettingsRowButton: View { + let icon: String? + let iconColor: Color + let title: String + let subtitle: String? + let destructive: Bool + let action: () -> Void + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + subtitle: String? = nil, + destructive: Bool = false, + action: @escaping () -> Void + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.subtitle = subtitle + self.destructive = destructive + self.action = action + } + + var body: some View { + Button(action: action) { + SettingsRowLabel( + icon: icon, + iconColor: destructive ? .red : iconColor, + title: title, + subtitle: subtitle, + titleColor: destructive ? .red : .primary + ) + } + } +} + +// MARK: - Settings Row with Picker +struct SettingsRowPicker: View { + let icon: String? + let iconColor: Color + let title: String + let selection: Binding + let options: [(value: T, label: String)] + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + selection: Binding, + options: [(value: T, label: String)] + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.selection = selection + self.options = options + } + + var body: some View { + HStack { + SettingsRowLabel( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: nil + ) + Spacer() + Picker("", selection: selection) { + ForEach(options, id: \.value) { option in + Text(option.label).tag(option.value) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + } +} + +// MARK: - Settings Row Label (internal component) +struct SettingsRowLabel: View { + let icon: String? + let iconColor: Color + let title: String + let subtitle: String? + let titleColor: Color + + init( + icon: String?, + iconColor: Color, + title: String, + subtitle: String?, + titleColor: Color = .primary + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.subtitle = subtitle + self.titleColor = titleColor + } + + var body: some View { + HStack(spacing: 12) { + if let icon = icon { + Image(systemName: icon) + .foregroundColor(iconColor) + .frame(width: 24) + } + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .foregroundColor(titleColor) + + if let subtitle = subtitle { + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } +} + +// MARK: - Previews +#Preview("Navigation Link") { + List { + SettingsRowNavigationLink( + icon: "paintbrush", + title: "App Icon", + subtitle: nil + ) { + Text("Detail View") + } + } + .listStyle(.insetGrouped) +} + +#Preview("Toggle") { + List { + SettingsRowToggle( + icon: "speaker.wave.2", + title: "Read Aloud Feature", + subtitle: "Text-to-Speech functionality", + isOn: .constant(true) + ) + } + .listStyle(.insetGrouped) +} + +#Preview("Value Display") { + List { + SettingsRowValue( + icon: "paintbrush.fill", + iconColor: .purple, + title: "Tint Color", + value: "Purple" + ) + } + .listStyle(.insetGrouped) +} + +#Preview("Button") { + List { + SettingsRowButton( + icon: "trash", + iconColor: .red, + title: "Clear Cache", + subtitle: "Remove all cached images", + destructive: true + ) { + print("Clear cache tapped") + } + } + .listStyle(.insetGrouped) +} + +#Preview("Picker") { + List { + SettingsRowPicker( + icon: "textformat", + title: "Font Family", + selection: .constant("System"), + options: [ + ("System", "System"), + ("Serif", "Serif"), + ("Monospace", "Monospace") + ] + ) + } + .listStyle(.insetGrouped) +} diff --git a/readeck/UI/Onboarding/OnboardingServerView.swift b/readeck/UI/Onboarding/OnboardingServerView.swift new file mode 100644 index 0000000..af812d1 --- /dev/null +++ b/readeck/UI/Onboarding/OnboardingServerView.swift @@ -0,0 +1,175 @@ +// +// OnboardingServerView.swift +// readeck +// +// Created by Ilyas Hallak on 31.10.25. +// + +import SwiftUI + +struct OnboardingServerView: View { + @State private var viewModel = SettingsServerViewModel() + + var body: some View { + VStack(spacing: 20) { + SectionHeader(title: "Server Settings".localized, icon: "server.rack") + .padding(.bottom, 4) + + Text("Enter your Readeck server details to get started.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.bottom, 8) + + // Form + VStack(spacing: 16) { + // Server Endpoint + VStack(alignment: .leading, spacing: 8) { + TextField("", + text: $viewModel.endpoint, + prompt: Text("Server Endpoint").foregroundColor(.secondary)) + .textFieldStyle(.roundedBorder) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: viewModel.endpoint) { + viewModel.clearMessages() + } + + // Quick Input Chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + QuickInputChip(text: "http://", action: { + if !viewModel.endpoint.starts(with: "http") { + viewModel.endpoint = "http://" + viewModel.endpoint + } + }) + QuickInputChip(text: "https://", action: { + if !viewModel.endpoint.starts(with: "http") { + viewModel.endpoint = "https://" + viewModel.endpoint + } + }) + QuickInputChip(text: "192.168.", action: { + if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" { + if viewModel.endpoint.starts(with: "http") { + viewModel.endpoint += "192.168." + } else { + viewModel.endpoint = "http://192.168." + } + } + }) + QuickInputChip(text: ":8000", action: { + if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") { + viewModel.endpoint += ":8000" + } + }) + } + .padding(.horizontal, 1) + } + + Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + // Username + VStack(alignment: .leading, spacing: 8) { + TextField("", + text: $viewModel.username, + prompt: Text("Username").foregroundColor(.secondary)) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: viewModel.username) { + viewModel.clearMessages() + } + } + + // Password + VStack(alignment: .leading, spacing: 8) { + SecureField("", + text: $viewModel.password, + prompt: Text("Password").foregroundColor(.secondary)) + .textFieldStyle(.roundedBorder) + .onChange(of: viewModel.password) { + viewModel.clearMessages() + } + } + } + + // Messages + if let errorMessage = viewModel.errorMessage { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + } + } + + if let successMessage = viewModel.successMessage { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(successMessage) + .foregroundColor(.green) + .font(.caption) + } + } + + VStack(spacing: 10) { + Button(action: { + Task { + await viewModel.saveServerSettings() + } + }) { + HStack { + if viewModel.isLoading { + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save")) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(viewModel.canLogin ? Color.accentColor : Color.gray) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(!viewModel.canLogin || viewModel.isLoading) + } + } + .task { + await viewModel.loadServerSettings() + } + } +} + +// MARK: - Quick Input Chip Component + +struct QuickInputChip: View { + let text: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(text) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(.systemGray5)) + .foregroundColor(.secondary) + .cornerRadius(12) + } + } +} + +#Preview { + OnboardingServerView() + .padding() +} diff --git a/readeck/UI/Settings/AppearanceSettingsView.swift b/readeck/UI/Settings/AppearanceSettingsView.swift index 8f3f453..c5780c5 100644 --- a/readeck/UI/Settings/AppearanceSettingsView.swift +++ b/readeck/UI/Settings/AppearanceSettingsView.swift @@ -3,62 +3,120 @@ import SwiftUI struct AppearanceSettingsView: View { @State private var selectedCardLayout: CardLayoutStyle = .magazine @State private var selectedTheme: Theme = .system - + @State private var fontViewModel: FontSettingsViewModel + @State private var generalViewModel: SettingsGeneralViewModel + private let loadCardLayoutUseCase: PLoadCardLayoutUseCase private let saveCardLayoutUseCase: PSaveCardLayoutUseCase private let settingsRepository: PSettingsRepository - - init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + + init( + factory: UseCaseFactory = DefaultUseCaseFactory.shared, + fontViewModel: FontSettingsViewModel = FontSettingsViewModel(), + generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel() + ) { self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase() self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase() self.settingsRepository = SettingsRepository() + self.fontViewModel = fontViewModel + self.generalViewModel = generalViewModel } - + var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "Appearance".localized, icon: "paintbrush") - .padding(.bottom, 4) - - // Theme Section - VStack(alignment: .leading, spacing: 12) { - Text("Theme") - .font(.headline) + Group { + Section { + // Font Family + Picker("Font family", selection: $fontViewModel.selectedFontFamily) { + ForEach(FontFamily.allCases, id: \.self) { family in + Text(family.displayName).tag(family) + } + } + .onChange(of: fontViewModel.selectedFontFamily) { + Task { + await fontViewModel.saveFontSettings() + } + } + + // Font Size + Picker("Font size", selection: $fontViewModel.selectedFontSize) { + ForEach(FontSize.allCases, id: \.self) { size in + Text(size.displayName).tag(size) + } + } + .pickerStyle(.segmented) + .onChange(of: fontViewModel.selectedFontSize) { + Task { + await fontViewModel.saveFontSettings() + } + } + + // Font Preview - direkt in der gleichen Section + VStack(alignment: .leading, spacing: 6) { + Text("readeck Bookmark Title") + .font(fontViewModel.previewTitleFont) + .fontWeight(.semibold) + .lineLimit(1) + + Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.") + .font(fontViewModel.previewBodyFont) + .lineLimit(3) + + Text("12 min β€’ Today β€’ example.com") + .font(fontViewModel.previewCaptionFont) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + .listRowBackground(Color(.systemGray6)) + + // Theme Picker (Menu statt Segmented) Picker("Theme", selection: $selectedTheme) { ForEach(Theme.allCases, id: \.self) { theme in Text(theme.displayName).tag(theme) } } - .pickerStyle(.segmented) .onChange(of: selectedTheme) { saveThemeSettings() } - } - - Divider() - - // Card Layout Section - VStack(alignment: .leading, spacing: 12) { - Text("Card Layout") - .font(.headline) - - VStack(spacing: 16) { - ForEach(CardLayoutStyle.allCases, id: \.self) { layout in - CardLayoutPreview( - layout: layout, - isSelected: selectedCardLayout == layout - ) { - selectedCardLayout = layout - saveCardLayoutSettings() - } + + // Card Layout als NavigationLink + NavigationLink { + CardLayoutSelectionView( + selectedCardLayout: $selectedCardLayout, + onSave: saveCardLayoutSettings + ) + } label: { + HStack { + Text("Card Layout") + Spacer() + Text(selectedCardLayout.displayName) + .foregroundColor(.secondary) } } + + // Open external links in + Picker("Open links in", selection: $generalViewModel.urlOpener) { + ForEach(UrlOpener.allCases, id: \.self) { urlOpener in + Text(urlOpener.displayName).tag(urlOpener) + } + } + .onChange(of: generalViewModel.urlOpener) { + Task { + await generalViewModel.saveGeneralSettings() + } + } + } header: { + Text("Appearance") + } footer: { + Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.") } } - .onAppear { + .task { + await fontViewModel.loadFontSettings() + await generalViewModel.loadGeneralSettings() loadSettings() } } - + private func loadSettings() { Task { // Load both theme and card layout from repository @@ -70,21 +128,21 @@ struct AppearanceSettingsView: View { selectedCardLayout = await loadCardLayoutUseCase.execute() } } - + private func saveThemeSettings() { Task { // Load current settings, update theme, and save back var settings = (try? await settingsRepository.loadSettings()) ?? Settings() settings.theme = selectedTheme try? await settingsRepository.saveSettings(settings) - + // Notify app about theme change await MainActor.run { NotificationCenter.default.post(name: .settingsChanged, object: nil) } } } - + private func saveCardLayoutSettings() { Task { await saveCardLayoutUseCase.execute(layout: selectedCardLayout) @@ -96,139 +154,11 @@ struct AppearanceSettingsView: View { } } - -struct CardLayoutPreview: View { - let layout: CardLayoutStyle - let isSelected: Bool - let onSelect: () -> Void - - var body: some View { - Button(action: onSelect) { - HStack(spacing: 12) { - // Visual Preview - switch layout { - case .compact: - // Compact: Small image on left, content on right - HStack(spacing: 8) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.blue.opacity(0.6)) - .frame(width: 24, height: 24) - - VStack(alignment: .leading, spacing: 2) { - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.8)) - .frame(height: 6) - .frame(maxWidth: .infinity) - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.6)) - .frame(height: 4) - .frame(maxWidth: 60) - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.4)) - .frame(height: 4) - .frame(maxWidth: 40) - } - } - .padding(8) - .background(Color.gray.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(width: 80, height: 50) - - case .magazine: - VStack(spacing: 4) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.blue.opacity(0.6)) - .frame(height: 24) - - VStack(alignment: .leading, spacing: 2) { - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.8)) - .frame(height: 5) - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.6)) - .frame(height: 4) - .frame(maxWidth: 40) - - Text("Fixed 140px") - .font(.system(size: 7)) - .foregroundColor(.secondary) - .padding(.top, 1) - } - .padding(.horizontal, 4) - } - .padding(6) - .background(Color.gray.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(width: 80, height: 65) - .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) - - case .natural: - VStack(spacing: 3) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.blue.opacity(0.6)) - .frame(height: 38) - - VStack(alignment: .leading, spacing: 2) { - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.8)) - .frame(height: 5) - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.6)) - .frame(height: 4) - .frame(maxWidth: 35) - - Text("Original ratio") - .font(.system(size: 7)) - .foregroundColor(.secondary) - .padding(.top, 1) - } - .padding(.horizontal, 4) - } - .padding(6) - .background(Color.gray.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(width: 80, height: 75) // HΓΆher als Magazine - .shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1) - } - - VStack(alignment: .leading, spacing: 4) { - Text(layout.displayName) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.primary) - - Text(layout.description) - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - } - - Spacer() - - if isSelected { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.blue) - .font(.title2) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) - ) +#Preview { + NavigationStack { + List { + AppearanceSettingsView() } - .buttonStyle(.plain) + .listStyle(.insetGrouped) } } - - -#Preview { - AppearanceSettingsView() - .cardStyle() - .padding() -} diff --git a/readeck/UI/Settings/CacheSettingsView.swift b/readeck/UI/Settings/CacheSettingsView.swift index b0ad22e..53b98b6 100644 --- a/readeck/UI/Settings/CacheSettingsView.swift +++ b/readeck/UI/Settings/CacheSettingsView.swift @@ -6,79 +6,67 @@ struct CacheSettingsView: View { @State private var maxCacheSize: Double = 200 @State private var isClearing: Bool = false @State private var showClearAlert: Bool = false - + var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "Cache Settings".localized, icon: "internaldrive") - .padding(.bottom, 4) - - VStack(spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Current Cache Size") - .foregroundColor(.primary) - Text("\(cacheSize) / \(Int(maxCacheSize)) MB max") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Button("Refresh") { - updateCacheSize() - } - .font(.caption) - .foregroundColor(.blue) + Section { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Current Cache Size") + Text("\(cacheSize) / \(Int(maxCacheSize)) MB max") + .font(.caption) + .foregroundColor(.secondary) } - - Divider() - - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Max Cache Size") - .foregroundColor(.primary) - Spacer() - Text("\(Int(maxCacheSize)) MB") - .font(.caption) - .foregroundColor(.secondary) - } - - Slider(value: $maxCacheSize, in: 50...1200, step: 50) { - Text("Max Cache Size") - } - .onChange(of: maxCacheSize) { _, newValue in - updateMaxCacheSize(newValue) - } - .accentColor(.blue) + Spacer() + Button("Refresh") { + updateCacheSize() } - - Divider() - - Button(action: { - showClearAlert = true - }) { - HStack { - if isClearing { - ProgressView() - .scaleEffect(0.8) - .frame(width: 24) - } else { - Image(systemName: "trash") - .foregroundColor(.red) - .frame(width: 24) - } - - VStack(alignment: .leading, spacing: 2) { - Text("Clear Cache") - .foregroundColor(isClearing ? .secondary : .red) - Text("Remove all cached images") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - } - } - .disabled(isClearing) + .font(.caption) + .foregroundColor(.blue) } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Max Cache Size") + Spacer() + Text("\(Int(maxCacheSize)) MB") + .font(.caption) + .foregroundColor(.secondary) + } + + Slider(value: $maxCacheSize, in: 50...1200, step: 50) { + Text("Max Cache Size") + } + .onChange(of: maxCacheSize) { _, newValue in + updateMaxCacheSize(newValue) + } + } + + Button(action: { + showClearAlert = true + }) { + HStack { + if isClearing { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "trash") + .foregroundColor(.red) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Clear Cache") + .foregroundColor(isClearing ? .secondary : .red) + Text("Remove all cached images") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + } + .disabled(isClearing) + } header: { + Text("Cache Settings") } .onAppear { updateCacheSize() @@ -93,7 +81,7 @@ struct CacheSettingsView: View { Text("This will remove all cached images. They will be downloaded again when needed.") } } - + private func updateCacheSize() { KingfisherManager.shared.cache.calculateDiskStorageSize { result in DispatchQueue.main.async { @@ -107,7 +95,7 @@ struct CacheSettingsView: View { } } } - + private func loadMaxCacheSize() { let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt if let savedSize = savedSize { @@ -120,29 +108,30 @@ struct CacheSettingsView: View { UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize") } } - + private func updateMaxCacheSize(_ newSize: Double) { let bytes = UInt(newSize * 1024 * 1024) KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize") } - + private func clearCache() { isClearing = true - + KingfisherManager.shared.cache.clearDiskCache { DispatchQueue.main.async { self.isClearing = false self.updateCacheSize() } } - + KingfisherManager.shared.cache.clearMemoryCache() } } #Preview { - CacheSettingsView() - .cardStyle() - .padding() -} \ No newline at end of file + List { + CacheSettingsView() + } + .listStyle(.insetGrouped) +} diff --git a/readeck/UI/Settings/CardLayoutSelectionView.swift b/readeck/UI/Settings/CardLayoutSelectionView.swift new file mode 100644 index 0000000..d3e7cce --- /dev/null +++ b/readeck/UI/Settings/CardLayoutSelectionView.swift @@ -0,0 +1,171 @@ +// +// CardLayoutSelectionView.swift +// readeck +// +// Created by Ilyas Hallak on 31.10.25. +// + +import SwiftUI + +struct CardLayoutSelectionView: View { + @Binding var selectedCardLayout: CardLayoutStyle + @Environment(\.dismiss) private var dismiss + + let onSave: () -> Void + + var body: some View { + List { + ForEach(CardLayoutStyle.allCases, id: \.self) { layout in + CardLayoutPreview( + layout: layout, + isSelected: selectedCardLayout == layout + ) { + selectedCardLayout = layout + onSave() + dismiss() + } + } + } + .listStyle(.plain) + .navigationTitle("Card Layout") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct CardLayoutPreview: View { + let layout: CardLayoutStyle + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 12) { + // Visual Preview + switch layout { + case .compact: + // Compact: Small image on left, content on right + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.blue.opacity(0.6)) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.8)) + .frame(height: 6) + .frame(maxWidth: .infinity) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.6)) + .frame(height: 4) + .frame(maxWidth: 60) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.4)) + .frame(height: 4) + .frame(maxWidth: 40) + } + } + .padding(8) + .background(Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 80, height: 50) + + case .magazine: + VStack(spacing: 4) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.blue.opacity(0.6)) + .frame(height: 24) + + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.8)) + .frame(height: 5) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.6)) + .frame(height: 4) + .frame(maxWidth: 40) + + Text("Fixed 140px") + .font(.system(size: 7)) + .foregroundColor(.secondary) + .padding(.top, 1) + } + .padding(.horizontal, 4) + } + .padding(6) + .background(Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 80, height: 65) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + + case .natural: + VStack(spacing: 3) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.blue.opacity(0.6)) + .frame(height: 38) + + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.8)) + .frame(height: 5) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.6)) + .frame(height: 4) + .frame(maxWidth: 35) + + Text("Original ratio") + .font(.system(size: 7)) + .foregroundColor(.secondary) + .padding(.top, 1) + } + .padding(.horizontal, 4) + } + .padding(6) + .background(Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 80, height: 75) + .shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1) + } + + VStack(alignment: .leading, spacing: 4) { + Text(layout.displayName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + + Text(layout.description) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + .font(.title2) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} + +#Preview { + NavigationStack { + CardLayoutSelectionView( + selectedCardLayout: .constant(.magazine), + onSave: {} + ) + } +} diff --git a/readeck/UI/Settings/FontSettingsView.swift b/readeck/UI/Settings/FontSettingsView.swift index 5dfd593..98d3d52 100644 --- a/readeck/UI/Settings/FontSettingsView.swift +++ b/readeck/UI/Settings/FontSettingsView.swift @@ -9,82 +9,60 @@ import SwiftUI struct FontSettingsView: View { @State private var viewModel: FontSettingsViewModel - + init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) { self.viewModel = viewModel } - + var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "Font Settings".localized, icon: "textformat") - .padding(.bottom, 4) - - // Font Family Picker - HStack(alignment: .firstTextBaseline, spacing: 16) { - Text("Font family") - .font(.headline) + Group { + Section { Picker("Font family", selection: $viewModel.selectedFontFamily) { ForEach(FontFamily.allCases, id: \.self) { family in Text(family.displayName).tag(family) } } - .pickerStyle(MenuPickerStyle()) .onChange(of: viewModel.selectedFontFamily) { Task { await viewModel.saveFontSettings() } } + + Picker("Font size", selection: $viewModel.selectedFontSize) { + ForEach(FontSize.allCases, id: \.self) { size in + Text(size.displayName).tag(size) + } + } + .pickerStyle(.segmented) + .onChange(of: viewModel.selectedFontSize) { + Task { + await viewModel.saveFontSettings() + } + } + } header: { + Text("Font Settings") } - - VStack(spacing: 16) { - - // Font Size Picker - VStack(alignment: .leading, spacing: 8) { - Text("Font size") - .font(.headline) - Picker("Font size", selection: $viewModel.selectedFontSize) { - ForEach(FontSize.allCases, id: \.self) { size in - Text(size.displayName).tag(size) - } - } - .pickerStyle(SegmentedPickerStyle()) - .onChange(of: viewModel.selectedFontSize) { - Task { - await viewModel.saveFontSettings() - } - } - } - - // Font Preview - VStack(alignment: .leading, spacing: 8) { - Text("Preview") - .font(.caption) + + Section { + VStack(alignment: .leading, spacing: 6) { + Text("readeck Bookmark Title") + .font(viewModel.previewTitleFont) + .fontWeight(.semibold) + .lineLimit(1) + + Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.") + .font(viewModel.previewBodyFont) + .lineLimit(3) + + Text("12 min β€’ Today β€’ example.com") + .font(viewModel.previewCaptionFont) .foregroundColor(.secondary) - - VStack(alignment: .leading, spacing: 6) { - Text("readeck Bookmark Title") - .font(viewModel.previewTitleFont) - .fontWeight(.semibold) - .lineLimit(1) - - Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.") - .font(viewModel.previewBodyFont) - .lineLimit(3) - - Text("12 min β€’ Today β€’ example.com") - .font(viewModel.previewCaptionFont) - .foregroundColor(.secondary) - } - .padding(4) - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } + .padding(.vertical, 4) + } header: { + Text("Preview") } } - .padding() - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) .task { await viewModel.loadFontSettings() } @@ -92,7 +70,10 @@ struct FontSettingsView: View { } #Preview { - FontSettingsView(viewModel: .init( - factory: MockUseCaseFactory()) - ) + List { + FontSettingsView(viewModel: .init( + factory: MockUseCaseFactory()) + ) + } + .listStyle(.insetGrouped) } diff --git a/readeck/UI/Settings/LegalPrivacySettingsView.swift b/readeck/UI/Settings/LegalPrivacySettingsView.swift index 45d35d1..aae51df 100644 --- a/readeck/UI/Settings/LegalPrivacySettingsView.swift +++ b/readeck/UI/Settings/LegalPrivacySettingsView.swift @@ -3,110 +3,67 @@ import SwiftUI struct LegalPrivacySettingsView: View { @State private var showingPrivacyPolicy = false @State private var showingLegalNotice = false - + var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text") - .padding(.bottom, 4) - - VStack(spacing: 16) { - // Privacy Policy + Group { + Section { Button(action: { showingPrivacyPolicy = true }) { HStack { Text(NSLocalizedString("Privacy Policy", comment: "")) - .font(.headline) - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") .font(.caption) .foregroundColor(.secondary) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) } - .buttonStyle(.plain) - - // Legal Notice + Button(action: { showingLegalNotice = true }) { HStack { Text(NSLocalizedString("Legal Notice", comment: "")) - .font(.headline) - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") .font(.caption) .foregroundColor(.secondary) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) } - .buttonStyle(.plain) - - Divider() - .padding(.vertical, 8) - - // Support Section - VStack(spacing: 12) { - // Report an Issue - Button(action: { - if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") { - UIApplication.shared.open(url) - } - }) { - HStack { - Text(NSLocalizedString("Report an Issue", comment: "")) - .font(.headline) - .foregroundColor(.primary) - - Spacer() - - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + } header: { + Text("Legal & Privacy") + } + + Section { + Button(action: { + if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") { + UIApplication.shared.open(url) } - .buttonStyle(.plain) - - // Contact Support - Button(action: { - if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") { - UIApplication.shared.open(url) - } - }) { - HStack { - Text(NSLocalizedString("Contact Support", comment: "")) - .font(.headline) - .foregroundColor(.primary) - - Spacer() - - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + }) { + HStack { + Text(NSLocalizedString("Report an Issue", comment: "")) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundColor(.secondary) } - .buttonStyle(.plain) } + + Button(action: { + if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") { + UIApplication.shared.open(url) + } + }) { + HStack { + Text(NSLocalizedString("Contact Support", comment: "")) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + } header: { + Text("Support") } } .sheet(isPresented: $showingPrivacyPolicy) { @@ -119,7 +76,8 @@ struct LegalPrivacySettingsView: View { } #Preview { - LegalPrivacySettingsView() - .cardStyle() - .padding() -} \ No newline at end of file + List { + LegalPrivacySettingsView() + } + .listStyle(.insetGrouped) +} diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift index 739b8d5..039db50 100644 --- a/readeck/UI/Settings/SettingsContainerView.swift +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -8,111 +8,81 @@ import SwiftUI struct SettingsContainerView: View { - + private var appVersion: String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" return "v\(version) (\(build))" } - + var body: some View { - ScrollView { - LazyVStack(spacing: 20) { - FontSettingsView() - .cardStyle() - - AppearanceSettingsView() - .cardStyle() - - CacheSettingsView() - .cardStyle() - - SettingsGeneralView() - .cardStyle() - - SettingsServerView() - .cardStyle() - - LegalPrivacySettingsView() - .cardStyle() - - // Debug-only Logging Configuration - if Bundle.main.isDebugBuild { - debugSettingsSection - } + List { + AppearanceSettingsView() + + CacheSettingsView() + + SettingsGeneralView() + + SettingsServerView() + + LegalPrivacySettingsView() + + // Debug-only Logging Configuration + #if DEBUG + if Bundle.main.isDebugBuild { + debugSettingsSection } - .padding() - .background(Color(.systemGroupedBackground)) - - AppInfo() - - Spacer() + #endif + + // App Info Section + appInfoSection } - .background(Color(.systemGroupedBackground)) + .listStyle(.insetGrouped) .navigationTitle("Settings") .navigationBarTitleDisplayMode(.large) } - + @ViewBuilder private var debugSettingsSection: some View { - VStack(alignment: .leading, spacing: 16) { + Section { + SettingsRowNavigationLink( + icon: "doc.text.magnifyingglass", + iconColor: .blue, + title: "Logging Configuration", + subtitle: "Configure log levels and categories" + ) { + LoggingConfigurationView() + } + } header: { HStack { - Image(systemName: "ant.fill") - .foregroundColor(.orange) Text("Debug Settings") - .font(.headline) - .foregroundColor(.primary) Spacer() Text("DEBUG BUILD") - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) .background(Color.orange.opacity(0.2)) .foregroundColor(.orange) .clipShape(Capsule()) } - - NavigationLink { - LoggingConfigurationView() - } label: { - HStack { - Image(systemName: "doc.text.magnifyingglass") - .foregroundColor(.blue) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text("Logging Configuration") - .foregroundColor(.primary) - Text("Configure log levels and categories") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) + } + } + + @ViewBuilder + private var appInfoSection: some View { + Section { + VStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text("Version \(appVersion)") + .font(.footnote) .foregroundColor(.secondary) } - } - } - .cardStyle() - } - - @ViewBuilder - func AppInfo() -> some View { - VStack(spacing: 4) { - HStack(spacing: 8) { - Image(systemName: "info.circle") - .foregroundColor(.secondary) - Text("Version \(appVersion)") - .font(.footnote) - .foregroundColor(.secondary) - } - HStack(spacing: 8) { - Image(systemName: "person.crop.circle") - .foregroundColor(.secondary) + HStack(spacing: 4) { + Image(systemName: "person.crop.circle") + .foregroundColor(.secondary) Text("Developer:") .font(.footnote) .foregroundColor(.secondary) @@ -123,26 +93,23 @@ struct SettingsContainerView: View { } .font(.footnote) .foregroundColor(.blue) - .underline() + } + + HStack(spacing: 8) { + Image(systemName: "globe") + .foregroundColor(.secondary) + Text("From Bremen with πŸ’š") + .font(.footnote) + .foregroundColor(.secondary) } } - HStack(spacing: 8) { - Image(systemName: "globe") - .foregroundColor(.secondary) - Text("From Bremen with πŸ’š") - .font(.footnote) - .foregroundColor(.secondary) - } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) } - .frame(maxWidth: .infinity) - .padding(.top, 16) - .padding(.bottom, 4) - .multilineTextAlignment(.center) - .opacity(0.7) } } -// Card Modifier fΓΌr einheitlichen Look +// Card Modifier fΓΌr einheitlichen Look (kept for backwards compatibility with other views) extension View { func cardStyle() -> some View { self @@ -154,5 +121,7 @@ extension View { } #Preview { - SettingsContainerView() -} + NavigationStack { + SettingsContainerView() + } +} diff --git a/readeck/UI/Settings/SettingsGeneralView.swift b/readeck/UI/Settings/SettingsGeneralView.swift index 0911db4..6d93175 100644 --- a/readeck/UI/Settings/SettingsGeneralView.swift +++ b/readeck/UI/Settings/SettingsGeneralView.swift @@ -16,15 +16,8 @@ struct SettingsGeneralView: View { } var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "General Settings".localized, icon: "gear") - .padding(.bottom, 4) - - VStack(alignment: .leading, spacing: 12) { - Text("General") - .font(.headline) - - // What's New Button + Group { + Section { Button(action: { showReleaseNotes = true }) { @@ -39,83 +32,57 @@ struct SettingsGeneralView: View { .foregroundColor(.secondary) } } - .buttonStyle(.plain) Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS) - .toggleStyle(.switch) .onChange(of: viewModel.enableTTS) { Task { await viewModel.saveGeneralSettings() } } + } header: { + Text("General") + } footer: { Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.") - .font(.footnote) } - - // Reading Settings - VStack(alignment: .leading, spacing: 12) { - Text("Open external links in".localized) - .font(.headline) - Picker("urlOpener", selection: $viewModel.urlOpener) { - ForEach(UrlOpener.allCases, id: \.self) { urlOpener in - Text(urlOpener.displayName.localized).tag(urlOpener) - } - } - .pickerStyle(.segmented) - .onChange(of: viewModel.urlOpener) { - Task { - await viewModel.saveGeneralSettings() - } - } - } - + #if DEBUG - // Sync Settings - VStack(alignment: .leading, spacing: 12) { - Text("Sync Settings") - .font(.headline) + Section { Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled) - .toggleStyle(SwitchToggleStyle()) if viewModel.autoSyncEnabled { - HStack { - Text("Sync interval") - Spacer() - Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60) - } + Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60) } + } header: { + Text("Sync Settings") } - - // Reading Settings - VStack(alignment: .leading, spacing: 12) { - Text("Reading Settings") - .font(.headline) + + Section { Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode) - .toggleStyle(SwitchToggleStyle()) Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead) - .toggleStyle(SwitchToggleStyle()) + } header: { + Text("Reading Settings") } - - // Messages + if let successMessage = viewModel.successMessage { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text(successMessage) - .foregroundColor(.green) - .font(.caption) + Section { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(successMessage) + .foregroundColor(.green) + } } } if let errorMessage = viewModel.errorMessage { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.red) - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + } } } #endif - } .sheet(isPresented: $showReleaseNotes) { ReleaseNotesView() @@ -127,7 +94,10 @@ struct SettingsGeneralView: View { } #Preview { - SettingsGeneralView(viewModel: .init( - MockUseCaseFactory() - )) + List { + SettingsGeneralView(viewModel: .init( + MockUseCaseFactory() + )) + } + .listStyle(.insetGrouped) } diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift index 618ee6a..5c95566 100644 --- a/readeck/UI/Settings/SettingsServerView.swift +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -11,189 +11,33 @@ struct SettingsServerView: View { @State private var viewModel = SettingsServerViewModel() @State private var showingLogoutAlert = false - init(showingLogoutAlert: Bool = false) { - self.showingLogoutAlert = showingLogoutAlert - } - var body: some View { - VStack(spacing: 20) { - SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack") - .padding(.bottom, 4) + Section { + SettingsRowValue( + icon: "server.rack", + title: "Server", + value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint + ) - Text(viewModel.isSetupMode ? - "Enter your Readeck server details to get started." : - "Your current server connection and login credentials.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.bottom, 8) + SettingsRowValue( + icon: "person.circle.fill", + title: "Username", + value: viewModel.username.isEmpty ? "Not set" : viewModel.username + ) - // Form - VStack(spacing: 16) { - // Server Endpoint - VStack(alignment: .leading, spacing: 8) { - if viewModel.isSetupMode { - TextField("", - text: $viewModel.endpoint, - prompt: Text("Server Endpoint").foregroundColor(.secondary)) - .textFieldStyle(.roundedBorder) - .keyboardType(.URL) - .autocapitalization(.none) - .disableAutocorrection(true) - .onChange(of: viewModel.endpoint) { - viewModel.clearMessages() - } - - // Quick Input Chips - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - QuickInputChip(text: "http://", action: { - if !viewModel.endpoint.starts(with: "http") { - viewModel.endpoint = "http://" + viewModel.endpoint - } - }) - QuickInputChip(text: "https://", action: { - if !viewModel.endpoint.starts(with: "http") { - viewModel.endpoint = "https://" + viewModel.endpoint - } - }) - QuickInputChip(text: "192.168.", action: { - if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" { - if viewModel.endpoint.starts(with: "http") { - viewModel.endpoint += "192.168." - } else { - viewModel.endpoint = "http://192.168." - } - } - }) - QuickInputChip(text: ":8000", action: { - if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") { - viewModel.endpoint += ":8000" - } - }) - } - .padding(.horizontal, 1) - } - - Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.") - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else { - HStack { - Image(systemName: "server.rack") - .foregroundColor(.accentColor) - Text(viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint) - .foregroundColor(viewModel.endpoint.isEmpty ? .secondary : .primary) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - } - - // Username - VStack(alignment: .leading, spacing: 8) { - if viewModel.isSetupMode { - TextField("", - text: $viewModel.username, - prompt: Text("Username").foregroundColor(.secondary)) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .disableAutocorrection(true) - .onChange(of: viewModel.username) { - viewModel.clearMessages() - } - } else { - HStack { - Image(systemName: "person.circle.fill") - .foregroundColor(.accentColor) - Text(viewModel.username.isEmpty ? "Not set" : viewModel.username) - .foregroundColor(viewModel.username.isEmpty ? .secondary : .primary) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - } - - // Password - if viewModel.isSetupMode { - VStack(alignment: .leading, spacing: 8) { - SecureField("", - text: $viewModel.password, - prompt: Text("Password").foregroundColor(.secondary)) - .textFieldStyle(.roundedBorder) - .onChange(of: viewModel.password) { - viewModel.clearMessages() - } - } - } - } - - // Messages - if let errorMessage = viewModel.errorMessage { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.red) - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) - } - } - - if let successMessage = viewModel.successMessage { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text(successMessage) - .foregroundColor(.green) - .font(.caption) - } - } - - if viewModel.isSetupMode { - VStack(spacing: 10) { - Button(action: { - Task { - await viewModel.saveServerSettings() - } - }) { - HStack { - if viewModel.isLoading { - ProgressView() - .scaleEffect(0.8) - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } - Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save")) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .background(viewModel.canLogin ? Color.accentColor : Color.gray) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(!viewModel.canLogin || viewModel.isLoading) - } - } else { - Button(action: { - showingLogoutAlert = true - }) { - HStack(spacing: 6) { - Image(systemName: "rectangle.portrait.and.arrow.right") - .font(.caption) - Text("Logout") - .font(.caption) - .fontWeight(.medium) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color(.systemGray5)) - .foregroundColor(.secondary) - .cornerRadius(8) - } + SettingsRowButton( + icon: "rectangle.portrait.and.arrow.right", + iconColor: .red, + title: "Logout", + subtitle: nil, + destructive: true + ) { + showingLogoutAlert = true } + } header: { + Text("Server Connection") + } footer: { + Text("Your current server connection and login credentials.") } .alert("Logout", isPresented: $showingLogoutAlert) { Button("Cancel", role: .cancel) { } @@ -211,22 +55,9 @@ struct SettingsServerView: View { } } -// MARK: - Quick Input Chip Component - -struct QuickInputChip: View { - let text: String - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(text) - .font(.caption) - .fontWeight(.medium) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color(.systemGray5)) - .foregroundColor(.secondary) - .cornerRadius(12) - } +#Preview { + List { + SettingsServerView() } + .listStyle(.insetGrouped) } diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 1f50ad0..7723755 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -20,7 +20,7 @@ struct readeckApp: App { if appViewModel.hasFinishedSetup { MainTabView() } else { - SettingsServerView() + OnboardingServerView() .padding() } } From 7338db5fab8ff3fc6ff6dc5ca4a82f944176aaa2 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 1 Nov 2025 13:54:40 +0100 Subject: [PATCH 32/39] Improve debug logging system - Redesign logging configuration UI with cleaner list-based navigation - Replace segmented controls with detailed selection screens for better UX - Add in-app debug log viewer with filtering and search capabilities - Implement opt-in logging toggle to reduce device performance impact - Add log storage system with 1000 entry limit - Enable log export via share sheet - Show warning banner when logging is disabled --- readeck/UI/Settings/DebugLogViewer.swift | 444 ++++++++++++++++++ .../Settings/LoggingConfigurationView.swift | 261 ++++++---- .../UI/Settings/SettingsContainerView.swift | 11 +- readeck/Utils/LogStore.swift | 145 ++++++ readeck/Utils/Logger.swift | 51 +- 5 files changed, 820 insertions(+), 92 deletions(-) create mode 100644 readeck/UI/Settings/DebugLogViewer.swift create mode 100644 readeck/Utils/LogStore.swift diff --git a/readeck/UI/Settings/DebugLogViewer.swift b/readeck/UI/Settings/DebugLogViewer.swift new file mode 100644 index 0000000..fea7136 --- /dev/null +++ b/readeck/UI/Settings/DebugLogViewer.swift @@ -0,0 +1,444 @@ +// +// DebugLogViewer.swift +// readeck +// +// Created by Ilyas Hallak on 01.11.25. +// + +import SwiftUI + +struct DebugLogViewer: View { + @State private var entries: [LogEntry] = [] + @State private var selectedLevel: LogLevel? + @State private var selectedCategory: LogCategory? + @State private var searchText = "" + @State private var showShareSheet = false + @State private var exportText = "" + @State private var autoScroll = true + @State private var showFilters = false + @StateObject private var logConfig = LogConfiguration.shared + + private let logger = Logger.ui + + var body: some View { + VStack(spacing: 0) { + // Logging Disabled Warning + if !logConfig.isLoggingEnabled { + loggingDisabledBanner + } + + // Filter Bar + if showFilters { + filterBar + } + + // Log List + if filteredEntries.isEmpty { + emptyState + } else { + ScrollViewReader { proxy in + List { + ForEach(filteredEntries) { entry in + LogEntryRow(entry: entry) + } + } + .listStyle(.plain) + .onChange(of: entries.count) { oldValue, newValue in + if autoScroll, let lastEntry = filteredEntries.last { + withAnimation { + proxy.scrollTo(lastEntry.id, anchor: .bottom) + } + } + } + } + } + } + .navigationTitle("Debug Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + Button { + showFilters.toggle() + } label: { + Label( + showFilters ? "Hide Filters" : "Show Filters", + systemImage: "line.3.horizontal.decrease.circle" + ) + } + + Button { + autoScroll.toggle() + } label: { + Label( + autoScroll ? "Disable Auto-Scroll" : "Enable Auto-Scroll", + systemImage: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle" + ) + } + + Divider() + + Button { + Task { + await refreshLogs() + } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + + Button { + Task { + await exportLogs() + } + } label: { + Label("Export Logs", systemImage: "square.and.arrow.up") + } + + Divider() + + Button(role: .destructive) { + Task { + await clearLogs() + } + } label: { + Label("Clear All Logs", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .searchable(text: $searchText, prompt: "Search logs") + .task { + await refreshLogs() + } + .sheet(isPresented: $showShareSheet) { + ActivityView(activityItems: [exportText]) + } + } + + @ViewBuilder + private var filterBar: some View { + VStack(spacing: 8) { + HStack { + Text("Filters") + .font(.headline) + Spacer() + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + // Level Filter + Menu { + Button("All Levels") { + selectedLevel = nil + } + Divider() + ForEach(LogLevel.allCases, id: \.self) { level in + Button { + selectedLevel = level + } label: { + HStack { + Text(levelName(for: level)) + if selectedLevel == level { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack { + Image(systemName: "slider.horizontal.3") + Text(selectedLevel != nil ? levelName(for: selectedLevel!) : "Level") + Image(systemName: "chevron.down") + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(selectedLevel != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5)) + .foregroundColor(selectedLevel != nil ? .accentColor : .primary) + .clipShape(Capsule()) + } + + // Category Filter + Menu { + Button("All Categories") { + selectedCategory = nil + } + Divider() + ForEach(LogCategory.allCases, id: \.self) { category in + Button { + selectedCategory = category + } label: { + HStack { + Text(category.rawValue) + if selectedCategory == category { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack { + Image(systemName: "tag") + Text(selectedCategory?.rawValue ?? "Category") + Image(systemName: "chevron.down") + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(selectedCategory != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5)) + .foregroundColor(selectedCategory != nil ? .accentColor : .primary) + .clipShape(Capsule()) + } + + // Clear Filters + if selectedLevel != nil || selectedCategory != nil { + Button { + selectedLevel = nil + selectedCategory = nil + } label: { + HStack { + Image(systemName: "xmark.circle.fill") + Text("Clear") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(.systemGray5)) + .foregroundColor(.secondary) + .clipShape(Capsule()) + } + } + } + .padding(.horizontal) + } + } + .padding(.vertical, 8) + .background(Color(.systemGroupedBackground)) + } + + @ViewBuilder + private var loggingDisabledBanner: some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.title3) + + VStack(alignment: .leading, spacing: 4) { + Text("Logging Disabled") + .font(.headline) + .foregroundColor(.primary) + + Text("Enable logging in settings to capture new logs") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button { + logConfig.isLoggingEnabled = true + } label: { + Text("Enable") + .font(.subheadline) + .fontWeight(.semibold) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.orange) + .foregroundColor(.white) + .clipShape(Capsule()) + } + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(12) + .padding(.horizontal) + .padding(.top, 8) + } + + @ViewBuilder + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 60)) + .foregroundColor(.secondary) + + Text("No Logs Found") + .font(.title2) + .fontWeight(.semibold) + + if !searchText.isEmpty || selectedLevel != nil || selectedCategory != nil { + Text("Try adjusting your filters or search criteria") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Button { + searchText = "" + selectedLevel = nil + selectedCategory = nil + } label: { + Text("Clear Filters") + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(Capsule()) + } + } else { + Text("Logs will appear here as they are generated") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private var filteredEntries: [LogEntry] { + var filtered = entries + + if let level = selectedLevel { + filtered = filtered.filter { $0.level == level } + } + + if let category = selectedCategory { + filtered = filtered.filter { $0.category == category } + } + + if !searchText.isEmpty { + filtered = filtered.filter { + $0.message.localizedCaseInsensitiveContains(searchText) || + $0.fileName.localizedCaseInsensitiveContains(searchText) || + $0.function.localizedCaseInsensitiveContains(searchText) + } + } + + return filtered + } + + private func refreshLogs() async { + entries = await LogStore.shared.getEntries() + } + + private func clearLogs() async { + await LogStore.shared.clear() + await refreshLogs() + logger.info("Cleared all debug logs") + } + + private func exportLogs() async { + exportText = await LogStore.shared.exportAsText() + showShareSheet = true + logger.info("Exported debug logs") + } + + private func levelName(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "Debug" + case 1: return "Info" + case 2: return "Notice" + case 3: return "Warning" + case 4: return "Error" + case 5: return "Critical" + default: return "Unknown" + } + } +} + +// MARK: - Log Entry Row + +struct LogEntryRow: View { + let entry: LogEntry + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + // Level Badge + Text(levelName(for: entry.level)) + .font(.caption) + .fontWeight(.semibold) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(levelColor(for: entry.level).opacity(0.2)) + .foregroundColor(levelColor(for: entry.level)) + .clipShape(Capsule()) + + // Category + Text(entry.category.rawValue) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + // Timestamp + Text(entry.formattedTimestamp) + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } + + // Message + Text(entry.message) + .font(.subheadline) + .foregroundColor(.primary) + + // Source Location + HStack(spacing: 4) { + Image(systemName: "doc.text") + .font(.caption2) + Text("\(entry.fileName):\(entry.line)") + .font(.caption) + Text("β€’") + .font(.caption) + Text(entry.function) + .font(.caption) + } + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + + private func levelName(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "DEBUG" + case 1: return "INFO" + case 2: return "NOTICE" + case 3: return "WARN" + case 4: return "ERROR" + case 5: return "CRITICAL" + default: return "UNKNOWN" + } + } + + private func levelColor(for level: LogLevel) -> Color { + switch level.rawValue { + case 0: return .blue + case 1: return .green + case 2: return .cyan + case 3: return .orange + case 4: return .red + case 5: return .purple + default: return .gray + } + } +} + +// MARK: - Activity View (for Share Sheet) + +struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +#Preview { + NavigationStack { + DebugLogViewer() + } +} diff --git a/readeck/UI/Settings/LoggingConfigurationView.swift b/readeck/UI/Settings/LoggingConfigurationView.swift index d9896e4..3688fff 100644 --- a/readeck/UI/Settings/LoggingConfigurationView.swift +++ b/readeck/UI/Settings/LoggingConfigurationView.swift @@ -5,97 +5,83 @@ // Created by Ilyas Hallak on 16.08.25. // - - import SwiftUI import os struct LoggingConfigurationView: View { @StateObject private var logConfig = LogConfiguration.shared private let logger = Logger.ui - + var body: some View { - NavigationView { - Form { - Section(header: Text("Global Settings")) { - VStack(alignment: .leading, spacing: 8) { - Text("Global Minimum Level") - .font(.headline) - - Picker("Global Level", selection: $logConfig.globalMinLevel) { - ForEach(LogLevel.allCases, id: \.self) { level in - HStack { - Text(level.emoji) - Text(level.rawValue == 0 ? "Debug" : - level.rawValue == 1 ? "Info" : - level.rawValue == 2 ? "Notice" : - level.rawValue == 3 ? "Warning" : - level.rawValue == 4 ? "Error" : "Critical") - } - .tag(level) - } + List { + Section { + Toggle("Enable Logging", isOn: $logConfig.isLoggingEnabled) + .tint(.green) + } header: { + Text("Logging Status") + } footer: { + Text("Enable logging to capture debug messages. When disabled, no logs are recorded to reduce device performance impact.") + } + + if logConfig.isLoggingEnabled { + Section { + NavigationLink { + GlobalLogLevelView(logConfig: logConfig) + } label: { + HStack { + Label("Global Log Level", systemImage: "slider.horizontal.3") + Spacer() + Text(levelName(for: logConfig.globalMinLevel)) + .foregroundColor(.secondary) } - .pickerStyle(SegmentedPickerStyle()) - - Text("Logs below this level will be filtered out globally") - .font(.caption) - .foregroundColor(.secondary) } - + Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs) Toggle("Show Timestamps", isOn: $logConfig.showTimestamps) Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation) - } - - Section(header: Text("Category-specific Levels")) { - ForEach(LogCategory.allCases, id: \.self) { category in - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(category.rawValue) - .font(.headline) - Spacer() - Text(levelName(for: logConfig.getLevel(for: category))) - .font(.caption) - .foregroundColor(.secondary) - } - - Picker("Level for \(category.rawValue)", selection: Binding( - get: { logConfig.getLevel(for: category) }, - set: { logConfig.setLevel($0, for: category) } - )) { - ForEach(LogLevel.allCases, id: \.self) { level in - HStack { - Text(level.emoji) - Text(levelName(for: level)) - } - .tag(level) - } - } - .pickerStyle(SegmentedPickerStyle()) - } - .padding(.vertical, 4) - } - } - - Section(header: Text("Reset")) { - Button("Reset to Defaults") { - resetToDefaults() - } - .foregroundColor(.orange) - } - - Section(footer: Text("Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).")) { - EmptyView() + } header: { + Text("Global Settings") + } footer: { + Text("Logs below the global level will be filtered out globally") + } + } + + if logConfig.isLoggingEnabled { + Section { + ForEach(LogCategory.allCases, id: \.self) { category in + NavigationLink { + CategoryLogLevelView(category: category, logConfig: logConfig) + } label: { + HStack { + Text(category.rawValue) + Spacer() + Text(levelName(for: logConfig.getLevel(for: category))) + .foregroundColor(.secondary) + } + } + } + } header: { + Text("Category-specific Levels") + } footer: { + Text("Configure log levels for each category individually") + } + } + + Section { + Button(role: .destructive) { + resetToDefaults() + } label: { + Label("Reset to Defaults", systemImage: "arrow.counterclockwise") } } - .navigationTitle("Logging Configuration") - .navigationBarTitleDisplayMode(.inline) } + .navigationTitle("Logging Configuration") + .navigationBarTitleDisplayMode(.inline) .onAppear { logger.debug("Opened logging configuration view") } } - + private func levelName(for level: LogLevel) -> String { switch level.rawValue { case 0: return "Debug" @@ -107,25 +93,140 @@ struct LoggingConfigurationView: View { default: return "Unknown" } } - + private func resetToDefaults() { logger.info("Resetting logging configuration to defaults") - - // Reset all category levels (this will use globalMinLevel as fallback) + for category in LogCategory.allCases { logConfig.setLevel(.debug, for: category) } - - // Reset global settings + logConfig.globalMinLevel = .debug logConfig.showPerformanceLogs = true logConfig.showTimestamps = true logConfig.includeSourceLocation = true - + logger.info("Logging configuration reset to defaults") } } -#Preview { - LoggingConfigurationView() +// MARK: - Global Log Level View + +struct GlobalLogLevelView: View { + @ObservedObject var logConfig: LogConfiguration + + var body: some View { + List { + ForEach(LogLevel.allCases, id: \.self) { level in + Button { + logConfig.globalMinLevel = level + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(levelName(for: level)) + .foregroundColor(.primary) + Text(levelDescription(for: level)) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if logConfig.globalMinLevel == level { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + } + .navigationTitle("Global Log Level") + .navigationBarTitleDisplayMode(.inline) + } + + private func levelName(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "Debug" + case 1: return "Info" + case 2: return "Notice" + case 3: return "Warning" + case 4: return "Error" + case 5: return "Critical" + default: return "Unknown" + } + } + + private func levelDescription(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "Show all logs including debug information" + case 1: return "Show informational messages and above" + case 2: return "Show notable events and above" + case 3: return "Show warnings and errors only" + case 4: return "Show errors and critical issues only" + case 5: return "Show only critical issues" + default: return "" + } + } +} + +// MARK: - Category Log Level View + +struct CategoryLogLevelView: View { + let category: LogCategory + @ObservedObject var logConfig: LogConfiguration + + var body: some View { + List { + ForEach(LogLevel.allCases, id: \.self) { level in + Button { + logConfig.setLevel(level, for: category) + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(levelName(for: level)) + .foregroundColor(.primary) + Text(levelDescription(for: level)) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if logConfig.getLevel(for: category) == level { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + } + .navigationTitle("\(category.rawValue) Logs") + .navigationBarTitleDisplayMode(.inline) + } + + private func levelName(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "Debug" + case 1: return "Info" + case 2: return "Notice" + case 3: return "Warning" + case 4: return "Error" + case 5: return "Critical" + default: return "Unknown" + } + } + + private func levelDescription(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "Show all logs including debug information" + case 1: return "Show informational messages and above" + case 2: return "Show notable events and above" + case 3: return "Show warnings and errors only" + case 4: return "Show errors and critical issues only" + case 5: return "Show only critical issues" + default: return "" + } + } +} + +#Preview { + NavigationStack { + LoggingConfigurationView() + } } diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift index 039db50..01425ea 100644 --- a/readeck/UI/Settings/SettingsContainerView.swift +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -46,8 +46,17 @@ struct SettingsContainerView: View { private var debugSettingsSection: some View { Section { SettingsRowNavigationLink( - icon: "doc.text.magnifyingglass", + icon: "list.bullet.rectangle", iconColor: .blue, + title: "Debug Logs", + subtitle: "View all debug messages" + ) { + DebugLogViewer() + } + + SettingsRowNavigationLink( + icon: "slider.horizontal.3", + iconColor: .purple, title: "Logging Configuration", subtitle: "Configure log levels and categories" ) { diff --git a/readeck/Utils/LogStore.swift b/readeck/Utils/LogStore.swift new file mode 100644 index 0000000..d3e85b6 --- /dev/null +++ b/readeck/Utils/LogStore.swift @@ -0,0 +1,145 @@ +// +// LogStore.swift +// readeck +// +// Created by Ilyas Hallak on 01.11.25. +// + +import Foundation + +// MARK: - Log Entry + +struct LogEntry: Identifiable, Codable { + let id: UUID + let timestamp: Date + let level: LogLevel + let category: LogCategory + let message: String + let file: String + let function: String + let line: Int + + var fileName: String { + URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "") + } + + var formattedTimestamp: String { + DateFormatter.logTimestamp.string(from: timestamp) + } + + init( + id: UUID = UUID(), + timestamp: Date = Date(), + level: LogLevel, + category: LogCategory, + message: String, + file: String, + function: String, + line: Int + ) { + self.id = id + self.timestamp = timestamp + self.level = level + self.category = category + self.message = message + self.file = file + self.function = function + self.line = line + } +} + +// MARK: - Log Store + +actor LogStore { + static let shared = LogStore() + + private var entries: [LogEntry] = [] + private let maxEntries: Int + + private init(maxEntries: Int = 1000) { + self.maxEntries = maxEntries + } + + func addEntry(_ entry: LogEntry) { + entries.append(entry) + + // Keep only the most recent entries + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + } + + func getEntries() -> [LogEntry] { + return entries + } + + func getEntries( + level: LogLevel? = nil, + category: LogCategory? = nil, + searchText: String? = nil + ) -> [LogEntry] { + var filtered = entries + + if let level = level { + filtered = filtered.filter { $0.level == level } + } + + if let category = category { + filtered = filtered.filter { $0.category == category } + } + + if let searchText = searchText, !searchText.isEmpty { + filtered = filtered.filter { + $0.message.localizedCaseInsensitiveContains(searchText) || + $0.fileName.localizedCaseInsensitiveContains(searchText) || + $0.function.localizedCaseInsensitiveContains(searchText) + } + } + + return filtered + } + + func clear() { + entries.removeAll() + } + + func exportAsText() -> String { + var text = "Readeck Debug Logs\n" + text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n" + text += "Total Entries: \(entries.count)\n" + text += String(repeating: "=", count: 80) + "\n\n" + + for entry in entries { + text += "[\(entry.formattedTimestamp)] " + text += "[\(entry.level.emoji) \(levelName(for: entry.level))] " + text += "[\(entry.category.rawValue)] " + text += "\(entry.fileName):\(entry.line) " + text += "\(entry.function)\n" + text += " \(entry.message)\n\n" + } + + return text + } + + private func levelName(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "DEBUG" + case 1: return "INFO" + case 2: return "NOTICE" + case 3: return "WARNING" + case 4: return "ERROR" + case 5: return "CRITICAL" + default: return "UNKNOWN" + } + } +} + +// MARK: - DateFormatter Extension + +extension DateFormatter { + static let exportTimestamp: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() +} diff --git a/readeck/Utils/Logger.swift b/readeck/Utils/Logger.swift index cc8f6c1..9f1feb1 100644 --- a/readeck/Utils/Logger.swift +++ b/readeck/Utils/Logger.swift @@ -10,14 +10,14 @@ import os // MARK: - Log Configuration -enum LogLevel: Int, CaseIterable { +enum LogLevel: Int, CaseIterable, Codable { 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 "πŸ”" @@ -30,7 +30,7 @@ enum LogLevel: Int, CaseIterable { } } -enum LogCategory: String, CaseIterable { +enum LogCategory: String, CaseIterable, Codable { case network = "Network" case ui = "UI" case data = "Data" @@ -43,13 +43,14 @@ enum LogCategory: String, CaseIterable { 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 - + @Published var isLoggingEnabled = false + private init() { loadConfiguration() } @@ -64,6 +65,7 @@ class LogConfiguration: ObservableObject { } func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool { + guard isLoggingEnabled else { return false } let categoryLevel = getLevel(for: category) return level.rawValue >= categoryLevel.rawValue } @@ -84,6 +86,7 @@ class LogConfiguration: ObservableObject { showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance") showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps") includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation") + isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled") } private func saveConfiguration() { @@ -96,6 +99,7 @@ class LogConfiguration: ObservableObject { UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance") UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps") UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation") + UserDefaults.standard.set(isLoggingEnabled, forKey: "LogIsEnabled") } } @@ -110,41 +114,66 @@ struct Logger { } // 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)") + storeLog(message: message, level: .debug, file: file, function: function, line: line) } - + 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)") + storeLog(message: message, level: .info, file: file, function: function, line: line) } - + 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)") + storeLog(message: message, level: .notice, file: file, function: function, line: line) } - + 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)") + storeLog(message: message, level: .warning, file: file, function: function, line: line) } - + 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)") + storeLog(message: message, level: .error, file: file, function: function, line: line) } - + 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)") + storeLog(message: message, level: .critical, file: file, function: function, line: line) + } + + // MARK: - Store Log + + private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) { + #if DEBUG + guard config.isLoggingEnabled else { return } + let entry = LogEntry( + level: level, + category: category, + message: message, + file: file, + function: function, + line: line + ) + Task { + await LogStore.shared.addEntry(entry) + } + #endif } // MARK: - Convenience Methods From 460b05ef34100335ac64e112f1eaacacceedc3f4 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 1 Nov 2025 14:03:39 +0100 Subject: [PATCH 33/39] Add delete annotation feature with swipe gesture Implemented ability to delete annotations via swipe-to-delete gesture in the annotations list view. Added close button with X icon to dismiss the annotations sheet. Changes: - Added DeleteAnnotationUseCase with repository integration - Extended API with DELETE endpoint for annotations - Implemented swipe-to-delete in AnnotationsListView - Added error handling and optimistic UI updates - Updated toolbar with close button (X icon) --- readeck.xcodeproj/project.pbxproj | 1 + readeck/Data/API/API.swift | 39 +++++++++++++++++++ .../Repository/AnnotationsRepository.swift | 4 ++ .../Protocols/PAnnotationsRepository.swift | 1 + .../UseCase/DeleteAnnotationUseCase.swift | 17 ++++++++ .../BookmarkDetail/AnnotationsListView.swift | 13 ++++++- .../AnnotationsListViewModel.swift | 13 +++++++ .../UI/Factory/DefaultUseCaseFactory.swift | 5 +++ readeck/UI/Factory/MockUseCaseFactory.swift | 10 +++++ 9 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 readeck/Domain/UseCase/DeleteAnnotationUseCase.swift diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 1f7387a..cb8aa43 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ UI/Components/UnifiedLabelChip.swift, UI/Utils/NotificationNames.swift, Utils/Logger.swift, + Utils/LogStore.swift, ); target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */; }; diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index 97cabd9..2e6fa09 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -20,6 +20,7 @@ protocol PAPI { func getBookmarkLabels() async throws -> [BookmarkLabelDto] func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto + func deleteAnnotation(bookmarkId: String, annotationId: String) async throws } class API: PAPI { @@ -486,6 +487,44 @@ class API: PAPI { logger.info("Successfully created annotation for bookmark: \(bookmarkId)") return result } + + func deleteAnnotation(bookmarkId: String, annotationId: String) async throws { + logger.info("Deleting annotation: \(annotationId) from bookmark: \(bookmarkId)") + + let baseURL = await self.baseURL + let fullEndpoint = "/api/bookmarks/\(bookmarkId)/annotations/\(annotationId)" + + guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { + logger.error("Invalid URL: \(baseURL)\(fullEndpoint)") + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if let token = await tokenProvider.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + logger.logNetworkRequest(method: "DELETE", url: url.absoluteString) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid HTTP response for DELETE \(url.absoluteString)") + throw APIError.invalidResponse + } + + guard 200...299 ~= httpResponse.statusCode else { + handleUnauthorizedResponse(httpResponse.statusCode) + logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) + throw APIError.serverError(httpResponse.statusCode) + } + + logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode) + logger.info("Successfully deleted annotation: \(annotationId)") + } } enum HTTPMethod: String { diff --git a/readeck/Data/Repository/AnnotationsRepository.swift b/readeck/Data/Repository/AnnotationsRepository.swift index e4a7afc..724becb 100644 --- a/readeck/Data/Repository/AnnotationsRepository.swift +++ b/readeck/Data/Repository/AnnotationsRepository.swift @@ -21,4 +21,8 @@ class AnnotationsRepository: PAnnotationsRepository { ) } } + + func deleteAnnotation(bookmarkId: String, annotationId: String) async throws { + try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId) + } } diff --git a/readeck/Domain/Protocols/PAnnotationsRepository.swift b/readeck/Domain/Protocols/PAnnotationsRepository.swift index 122b67c..9078c28 100644 --- a/readeck/Domain/Protocols/PAnnotationsRepository.swift +++ b/readeck/Domain/Protocols/PAnnotationsRepository.swift @@ -1,3 +1,4 @@ protocol PAnnotationsRepository { func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] + func deleteAnnotation(bookmarkId: String, annotationId: String) async throws } diff --git a/readeck/Domain/UseCase/DeleteAnnotationUseCase.swift b/readeck/Domain/UseCase/DeleteAnnotationUseCase.swift new file mode 100644 index 0000000..a785b73 --- /dev/null +++ b/readeck/Domain/UseCase/DeleteAnnotationUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol PDeleteAnnotationUseCase { + func execute(bookmarkId: String, annotationId: String) async throws +} + +class DeleteAnnotationUseCase: PDeleteAnnotationUseCase { + private let repository: PAnnotationsRepository + + init(repository: PAnnotationsRepository) { + self.repository = repository + } + + func execute(bookmarkId: String, annotationId: String) async throws { + try await repository.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId) + } +} diff --git a/readeck/UI/BookmarkDetail/AnnotationsListView.swift b/readeck/UI/BookmarkDetail/AnnotationsListView.swift index 4c63668..16b6ef0 100644 --- a/readeck/UI/BookmarkDetail/AnnotationsListView.swift +++ b/readeck/UI/BookmarkDetail/AnnotationsListView.swift @@ -64,6 +64,15 @@ struct AnnotationsListView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + Task { + await viewModel.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotation.id) + } + } label: { + Label("Delete", systemImage: "trash") + } + } } case .error: @@ -74,8 +83,10 @@ struct AnnotationsListView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { + Button { dismiss() + } label: { + Image(systemName: "xmark") } } } diff --git a/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift b/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift index aede002..9f120a9 100644 --- a/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift +++ b/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift @@ -3,6 +3,7 @@ import Foundation @Observable class AnnotationsListViewModel { private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase + private let deleteAnnotationUseCase: PDeleteAnnotationUseCase var annotations: [Annotation] = [] var isLoading = false @@ -11,6 +12,7 @@ class AnnotationsListViewModel { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase() + self.deleteAnnotationUseCase = factory.makeDeleteAnnotationUseCase() } @MainActor @@ -26,4 +28,15 @@ class AnnotationsListViewModel { showErrorAlert = true } } + + @MainActor + func deleteAnnotation(bookmarkId: String, annotationId: String) async { + do { + try await deleteAnnotationUseCase.execute(bookmarkId: bookmarkId, annotationId: annotationId) + annotations.removeAll { $0.id == annotationId } + } catch { + errorMessage = "Failed to delete annotation" + showErrorAlert = true + } + } } diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index 1a69ce2..34798d7 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -22,6 +22,7 @@ protocol UseCaseFactory { func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase + func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase } @@ -125,4 +126,8 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase { return GetBookmarkAnnotationsUseCase(repository: annotationsRepository) } + + func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase { + return DeleteAnnotationUseCase(repository: annotationsRepository) + } } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index 6bf191c..fb83faf 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -92,6 +92,10 @@ class MockUseCaseFactory: UseCaseFactory { func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase { MockGetBookmarkAnnotationsUseCase() } + + func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase { + MockDeleteAnnotationUseCase() + } } @@ -250,6 +254,12 @@ class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase { } } +class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase { + func execute(bookmarkId: String, annotationId: String) async throws { + // Mock implementation - do nothing + } +} + extension Bookmark { static let mock: Bookmark = .init( id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil) From f3719fa9d41da6cabfc04414a5dc2df99c24b728 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 8 Nov 2025 13:46:40 +0100 Subject: [PATCH 34/39] Refactor tag management to use Core Data with configurable sorting This commit introduces a comprehensive refactoring of the tag management system, replacing the previous API-based approach with a Core Data-first strategy for improved performance and offline support. Major Changes: Tag Management Architecture: - Add CoreDataTagManagementView using @FetchRequest for reactive updates - Implement cache-first sync strategy in LabelsRepository - Create SyncTagsUseCase following Clean Architecture principles - Add TagSortOrder enum for configurable tag sorting (by count/alphabetically) - Mark LegacyTagManagementView as deprecated Share Extension Improvements: - Replace API-based tag loading with Core Data queries - Display top 150 tags sorted by usage count - Remove unnecessary label fetching logic - Add "Most used tags" localized title - Improve offline bookmark tag management Main App Enhancements: - Add tag sync triggers in AddBookmarkView and BookmarkLabelsView - Implement user-configurable tag sorting in settings - Add sort order indicator labels with localization - Automatic UI updates via SwiftUI @FetchRequest reactivity Settings & Configuration: - Add TagSortOrder setting with persistence - Refactor Settings model structure - Add FontFamily and FontSize domain models - Improve settings repository with tag sort order support Use Case Layer: - Add SyncTagsUseCase for background tag synchronization - Update UseCaseFactory with tag sync support - Add mock implementations for testing Localization: - Add German and English translations for: - "Most used tags" - "Sorted by usage count" - "Sorted alphabetically" Technical Improvements: - Batch tag updates with conflict detection - Background sync with silent failure handling - Reduced server load through local caching - Better separation of concerns following Clean Architecture --- URLShare/OfflineBookmarkManager.swift | 48 ++++ URLShare/ShareBookmarkView.swift | 70 +++-- URLShare/ShareBookmarkViewModel.swift | 92 +------ URLShare/ShareViewController.swift | 9 +- readeck.xcodeproj/project.pbxproj | 12 +- readeck/Data/Mappers/TagEntityMapper.swift | 3 +- .../Data/Repository/LabelsRepository.swift | 34 ++- .../Data/Repository/SettingsRepository.swift | 82 ++++-- readeck/Domain/Model/FontFamily.swift | 23 ++ readeck/Domain/Model/FontSize.swift | 33 +++ readeck/Domain/Model/Settings.swift | 32 +++ readeck/Domain/Model/TagSortOrder.swift | 20 ++ readeck/Domain/Model/UrlOpener.swift | 7 + readeck/Domain/UseCase/SyncTagsUseCase.swift | 21 ++ .../de.lproj/Localizable.strings | 3 + .../en.lproj/Localizable.strings | 3 + readeck/UI/AddBookmark/AddBookmarkView.swift | 47 ++-- .../UI/AddBookmark/AddBookmarkViewModel.swift | 12 +- .../BookmarkDetail/BookmarkLabelsView.swift | 60 +++-- .../BookmarkLabelsViewModel.swift | 12 +- .../CoreDataTagManagementView.swift | 255 ++++++++++++++++++ ...ew.swift => LegacyTagManagementView.swift} | 6 +- readeck/UI/Extension/FontSizeExtension.swift | 14 + .../UI/Factory/DefaultUseCaseFactory.swift | 9 +- readeck/UI/Factory/MockUseCaseFactory.swift | 12 +- readeck/UI/Models/AppSettings.swift | 10 +- .../UI/Settings/AppearanceSettingsView.swift | 32 ++- .../UI/Settings/FontSettingsViewModel.swift | 46 +--- readeck/UI/Utils/NotificationNames.swift | 1 + readeck/UI/readeckApp.swift | 1 + .../readeck.xcdatamodel/contents | 2 + 31 files changed, 747 insertions(+), 264 deletions(-) create mode 100644 readeck/Domain/Model/FontFamily.swift create mode 100644 readeck/Domain/Model/FontSize.swift create mode 100644 readeck/Domain/Model/Settings.swift create mode 100644 readeck/Domain/Model/TagSortOrder.swift create mode 100644 readeck/Domain/UseCase/SyncTagsUseCase.swift create mode 100644 readeck/UI/Components/CoreDataTagManagementView.swift rename readeck/UI/Components/{TagManagementView.swift => LegacyTagManagementView.swift} (97%) create mode 100644 readeck/UI/Extension/FontSizeExtension.swift diff --git a/URLShare/OfflineBookmarkManager.swift b/URLShare/OfflineBookmarkManager.swift index 28eb785..3aa825d 100644 --- a/URLShare/OfflineBookmarkManager.swift +++ b/URLShare/OfflineBookmarkManager.swift @@ -84,6 +84,7 @@ class OfflineBookmarkManager: @unchecked Sendable { if !existingNames.contains(tag) { let entity = TagEntity(context: backgroundContext) entity.name = tag + entity.count = 0 insertCount += 1 } } @@ -98,5 +99,52 @@ class OfflineBookmarkManager: @unchecked Sendable { print("Failed to save tags: \(error)") } } + + func saveTagsWithCount(_ tags: [BookmarkLabelDto]) async { + let backgroundContext = CoreDataManager.shared.newBackgroundContext() + + do { + try await backgroundContext.perform { + // Batch fetch existing tags + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.propertiesToFetch = ["name"] + + let existingEntities = try backgroundContext.fetch(fetchRequest) + var existingByName: [String: TagEntity] = [:] + for entity in existingEntities { + if let name = entity.name { + existingByName[name] = entity + } + } + + // Insert or update tags + var insertCount = 0 + var updateCount = 0 + for tag in tags { + if let existing = existingByName[tag.name] { + // Update count if changed + if existing.count != tag.count { + existing.count = Int32(tag.count) + updateCount += 1 + } + } else { + // Insert new tag + let entity = TagEntity(context: backgroundContext) + entity.name = tag.name + entity.count = Int32(tag.count) + insertCount += 1 + } + } + + // Only save if there are changes + if insertCount > 0 || updateCount > 0 { + try backgroundContext.save() + print("Saved \(insertCount) new tags and updated \(updateCount) tags to Core Data") + } + } + } catch { + print("Failed to save tags with count: \(error)") + } + } } diff --git a/URLShare/ShareBookmarkView.swift b/URLShare/ShareBookmarkView.swift index 2e78d09..5778a21 100644 --- a/URLShare/ShareBookmarkView.swift +++ b/URLShare/ShareBookmarkView.swift @@ -1,9 +1,12 @@ import SwiftUI +import CoreData struct ShareBookmarkView: View { @ObservedObject var viewModel: ShareBookmarkViewModel @State private var keyboardHeight: CGFloat = 0 @FocusState private var focusedField: AddBookmarkFieldFocus? + + @Environment(\.managedObjectContext) private var viewContext private func dismissKeyboard() { NotificationCenter.default.post(name: .dismissKeyboard, object: nil) @@ -39,7 +42,6 @@ struct ShareBookmarkView: View { saveButtonSection } .background(Color(.systemGroupedBackground)) - .onAppear { viewModel.onAppear() } .ignoresSafeArea(.keyboard, edges: .bottom) .contentShape(Rectangle()) .onTapGesture { @@ -134,32 +136,30 @@ struct ShareBookmarkView: View { @ViewBuilder private var tagManagementSection: some View { - if !viewModel.labels.isEmpty || !viewModel.isServerReachable { - TagManagementView( - allLabels: convertToBookmarkLabels(viewModel.labels), - selectedLabels: viewModel.selectedLabels, - searchText: $viewModel.searchText, - isLabelsLoading: false, - filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels), - searchFieldFocus: $focusedField, - onAddCustomTag: { - addCustomTag() - }, - onToggleLabel: { label in - if viewModel.selectedLabels.contains(label) { - viewModel.selectedLabels.remove(label) - } else { - viewModel.selectedLabels.insert(label) - } - viewModel.searchText = "" - }, - onRemoveLabel: { label in + CoreDataTagManagementView( + selectedLabels: viewModel.selectedLabels, + searchText: $viewModel.searchText, + searchFieldFocus: $focusedField, + fetchLimit: 150, + sortOrder: viewModel.tagSortOrder, + availableTagsTitle: "Most used tags", + onAddCustomTag: { + addCustomTag() + }, + onToggleLabel: { label in + if viewModel.selectedLabels.contains(label) { viewModel.selectedLabels.remove(label) + } else { + viewModel.selectedLabels.insert(label) } - ) - .padding(.top, 20) - .padding(.horizontal, 16) - } + viewModel.searchText = "" + }, + onRemoveLabel: { label in + viewModel.selectedLabels.remove(label) + } + ) + .padding(.top, 20) + .padding(.horizontal, 16) } @ViewBuilder @@ -198,25 +198,21 @@ struct ShareBookmarkView: View { } // MARK: - Helper Functions - - private func convertToBookmarkLabels(_ dtos: [BookmarkLabelDto]) -> [BookmarkLabel] { - return dtos.map { .init(name: $0.name, count: $0.count, href: $0.href) } - } - - private func convertToBookmarkLabelPages(_ dtoPages: [[BookmarkLabelDto]]) -> [[BookmarkLabel]] { - return dtoPages.map { convertToBookmarkLabels($0) } - } - + private func addCustomTag() { let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText) - let availableLabels = viewModel.labels.map { $0.name } + + // Fetch available labels from Core Data + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + let availableLabels = (try? viewContext.fetch(fetchRequest))?.compactMap { $0.name } ?? [] + let currentLabels = Array(viewModel.selectedLabels) let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels) - + for label in uniqueLabels { viewModel.selectedLabels.insert(label) } - + viewModel.searchText = "" } } diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index d01a9a8..66941a8 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -6,54 +6,23 @@ import CoreData class ShareBookmarkViewModel: ObservableObject { @Published var url: String? @Published var title: String = "" - @Published var labels: [BookmarkLabelDto] = [] @Published var selectedLabels: Set = [] @Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil @Published var isSaving: Bool = false @Published var searchText: String = "" @Published var isServerReachable: Bool = true + let tagSortOrder: TagSortOrder = .byCount // Share Extension always uses byCount let extensionContext: NSExtensionContext? private let logger = Logger.viewModel private let serverCheck = ShareExtensionServerCheck.shared - - var availableLabels: [BookmarkLabelDto] { - return labels.filter { !selectedLabels.contains($0.name) } - } - - // filtered labels based on search text - var filteredLabels: [BookmarkLabelDto] { - if searchText.isEmpty { - return availableLabels - } else { - return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - } - } - - var availableLabelPages: [[BookmarkLabelDto]] { - let pageSize = 12 // Extension can't access Constants.Labels.pageSize - let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels - - if labelsToShow.count <= pageSize { - return [labelsToShow] - } else { - return stride(from: 0, to: labelsToShow.count, by: pageSize).map { - Array(labelsToShow[$0.. $1.count } - await MainActor.run { - self.labels = Array(sorted) - self.logger.info("Synced \(loaded.count) labels from API and updated cache") - measurement.end() - } - } else { - measurement.end() - } - } - } - func save() { logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)") guard let url = url, !url.isEmpty else { @@ -205,19 +129,23 @@ class ShareBookmarkViewModel: ObservableObject { ) logger.info("Local save result: \(success)") - DispatchQueue.main.async { + await MainActor.run { self.isSaving = false if success { self.logger.info("Bookmark saved locally successfully") self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.completeExtensionRequest() - } } else { self.logger.error("Failed to save bookmark locally") self.statusMessage = ("Failed to save locally.", true, "❌") } } + + if success { + try? await Task.sleep(nanoseconds: 2_000_000_000) + await MainActor.run { + self.completeExtensionRequest() + } + } } } } diff --git a/URLShare/ShareViewController.swift b/URLShare/ShareViewController.swift index 64a511d..c398568 100644 --- a/URLShare/ShareViewController.swift +++ b/URLShare/ShareViewController.swift @@ -11,14 +11,15 @@ import UniformTypeIdentifiers import SwiftUI class ShareViewController: UIViewController { - - private var hostingController: UIHostingController? - + + private var hostingController: UIHostingController? + override func viewDidLoad() { super.viewDidLoad() let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext) let swiftUIView = ShareBookmarkView(viewModel: viewModel) - let hostingController = UIHostingController(rootView: swiftUIView) + .environment(\.managedObjectContext, CoreDataManager.shared.context) + let hostingController = UIHostingController(rootView: AnyView(swiftUIView)) addChild(hostingController) hostingController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(hostingController.view) diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index cb8aa43..7e456a5 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -87,12 +87,22 @@ Data/Utils/LabelUtils.swift, Domain/Model/Bookmark.swift, Domain/Model/BookmarkLabel.swift, + Domain/Model/CardLayoutStyle.swift, + Domain/Model/FontFamily.swift, + Domain/Model/FontSize.swift, + Domain/Model/Settings.swift, + Domain/Model/TagSortOrder.swift, + Domain/Model/Theme.swift, + Domain/Model/UrlOpener.swift, readeck.xcdatamodeld, Splash.storyboard, UI/Components/Constants.swift, + UI/Components/CoreDataTagManagementView.swift, UI/Components/CustomTextFieldStyle.swift, - UI/Components/TagManagementView.swift, + UI/Components/LegacyTagManagementView.swift, UI/Components/UnifiedLabelChip.swift, + UI/Extension/FontSizeExtension.swift, + UI/Models/AppSettings.swift, UI/Utils/NotificationNames.swift, Utils/Logger.swift, Utils/LogStore.swift, diff --git a/readeck/Data/Mappers/TagEntityMapper.swift b/readeck/Data/Mappers/TagEntityMapper.swift index 0e1ea77..12a1c18 100644 --- a/readeck/Data/Mappers/TagEntityMapper.swift +++ b/readeck/Data/Mappers/TagEntityMapper.swift @@ -9,11 +9,12 @@ import Foundation import CoreData extension BookmarkLabelDto { - + @discardableResult func toEntity(context: NSManagedObjectContext) -> TagEntity { let entity = TagEntity(context: context) entity.name = name + entity.count = Int32(count) return entity } } diff --git a/readeck/Data/Repository/LabelsRepository.swift b/readeck/Data/Repository/LabelsRepository.swift index 08e8427..989e433 100644 --- a/readeck/Data/Repository/LabelsRepository.swift +++ b/readeck/Data/Repository/LabelsRepository.swift @@ -33,14 +33,17 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable { return try await backgroundContext.perform { let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + fetchRequest.sortDescriptors = [ + NSSortDescriptor(key: "count", ascending: false), + NSSortDescriptor(key: "name", ascending: true) + ] let entities = try backgroundContext.fetch(fetchRequest) return entities.compactMap { entity -> BookmarkLabel? in guard let name = entity.name, !name.isEmpty else { return nil } return BookmarkLabel( name: name, - count: 0, + count: Int(entity.count), href: name ) } @@ -51,24 +54,37 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable { let backgroundContext = coreDataManager.newBackgroundContext() try await backgroundContext.perform { - // Batch fetch all existing label names (much faster than individual queries) + // Batch fetch all existing labels let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() - fetchRequest.propertiesToFetch = ["name"] + fetchRequest.propertiesToFetch = ["name", "count"] let existingEntities = try backgroundContext.fetch(fetchRequest) - let existingNames = Set(existingEntities.compactMap { $0.name }) + var existingByName: [String: TagEntity] = [:] + for entity in existingEntities { + if let name = entity.name { + existingByName[name] = entity + } + } - // Only insert new labels + // Insert or update labels var insertCount = 0 + var updateCount = 0 for dto in dtos { - if !existingNames.contains(dto.name) { + if let existing = existingByName[dto.name] { + // Update count if changed + if existing.count != dto.count { + existing.count = Int32(dto.count) + updateCount += 1 + } + } else { + // Insert new label dto.toEntity(context: backgroundContext) insertCount += 1 } } - // Only save if there are new labels - if insertCount > 0 { + // Only save if there are changes + if insertCount > 0 || updateCount > 0 { try backgroundContext.save() } } diff --git a/readeck/Data/Repository/SettingsRepository.swift b/readeck/Data/Repository/SettingsRepository.swift index 2d4eaf8..fe82342 100644 --- a/readeck/Data/Repository/SettingsRepository.swift +++ b/readeck/Data/Repository/SettingsRepository.swift @@ -1,30 +1,6 @@ import Foundation import CoreData -struct Settings { - var endpoint: String? = nil - var username: String? = nil - var password: String? = nil - var token: String? = nil - - var fontFamily: FontFamily? = nil - var fontSize: FontSize? = nil - var hasFinishedSetup: Bool = false - var enableTTS: Bool? = nil - var theme: Theme? = nil - var cardLayoutStyle: CardLayoutStyle? = nil - - var urlOpener: UrlOpener? = nil - - var isLoggedIn: Bool { - token != nil && !token!.isEmpty - } - - mutating func setToken(_ newToken: String) { - token = newToken - } -} - protocol PSettingsRepository { func saveSettings(_ settings: Settings) async throws func loadSettings() async throws -> Settings? @@ -33,9 +9,11 @@ protocol PSettingsRepository { func saveUsername(_ username: String) async throws func savePassword(_ password: String) async throws func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws - func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws + func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws func loadCardLayoutStyle() async throws -> CardLayoutStyle + func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws + func loadTagSortOrder() async throws -> TagSortOrder var hasFinishedSetup: Bool { get } } @@ -100,7 +78,11 @@ class SettingsRepository: PSettingsRepository { if let cardLayoutStyle = settings.cardLayoutStyle { existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue } - + + if let tagSortOrder = settings.tagSortOrder { + existingSettings.tagSortOrder = tagSortOrder.rawValue + } + try context.save() continuation.resume() } catch { @@ -139,6 +121,7 @@ class SettingsRepository: PSettingsRepository { enableTTS: settingEntity?.enableTTS, theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue), cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue), + tagSortOrder: TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue), urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue) ) continuation.resume(returning: settings) @@ -244,16 +227,16 @@ class SettingsRepository: PSettingsRepository { func loadCardLayoutStyle() async throws -> CardLayoutStyle { let context = coreDataManager.context - + return try await withCheckedThrowingContinuation { continuation in context.perform { do { let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() fetchRequest.fetchLimit = 1 - + let settingEntities = try context.fetch(fetchRequest) let settingEntity = settingEntities.first - + let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine continuation.resume(returning: cardLayoutStyle) } catch { @@ -262,4 +245,45 @@ class SettingsRepository: PSettingsRepository { } } } + + func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws { + let context = coreDataManager.context + + return try await withCheckedThrowingContinuation { continuation in + context.perform { + do { + let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() + let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context) + + existingSettings.tagSortOrder = tagSortOrder.rawValue + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func loadTagSortOrder() async throws -> TagSortOrder { + let context = coreDataManager.context + + return try await withCheckedThrowingContinuation { continuation in + context.perform { + do { + let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() + fetchRequest.fetchLimit = 1 + + let settingEntities = try context.fetch(fetchRequest) + let settingEntity = settingEntities.first + + let tagSortOrder = TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue) ?? .byCount + continuation.resume(returning: tagSortOrder) + } catch { + continuation.resume(throwing: error) + } + } + } + } } diff --git a/readeck/Domain/Model/FontFamily.swift b/readeck/Domain/Model/FontFamily.swift new file mode 100644 index 0000000..c29219f --- /dev/null +++ b/readeck/Domain/Model/FontFamily.swift @@ -0,0 +1,23 @@ +// +// FontFamily.swift +// readeck +// +// Created by Ilyas Hallak on 06.11.25. +// + + +enum FontFamily: String, CaseIterable { + case system = "system" + case serif = "serif" + case sansSerif = "sansSerif" + case monospace = "monospace" + + var displayName: String { + switch self { + case .system: return "System" + case .serif: return "Serif" + case .sansSerif: return "Sans Serif" + case .monospace: return "Monospace" + } + } +} \ No newline at end of file diff --git a/readeck/Domain/Model/FontSize.swift b/readeck/Domain/Model/FontSize.swift new file mode 100644 index 0000000..0f50d45 --- /dev/null +++ b/readeck/Domain/Model/FontSize.swift @@ -0,0 +1,33 @@ +// +// FontSize.swift +// readeck +// +// Created by Ilyas Hallak on 06.11.25. +// + +import Foundation + +enum FontSize: String, CaseIterable { + case small = "small" + case medium = "medium" + case large = "large" + case extraLarge = "extraLarge" + + var displayName: String { + switch self { + case .small: return "S" + case .medium: return "M" + case .large: return "L" + case .extraLarge: return "XL" + } + } + + var size: CGFloat { + switch self { + case .small: return 14 + case .medium: return 16 + case .large: return 18 + case .extraLarge: return 20 + } + } +} diff --git a/readeck/Domain/Model/Settings.swift b/readeck/Domain/Model/Settings.swift new file mode 100644 index 0000000..386502b --- /dev/null +++ b/readeck/Domain/Model/Settings.swift @@ -0,0 +1,32 @@ +// +// Settings.swift +// readeck +// +// Created by Ilyas Hallak on 06.11.25. +// + + +struct Settings { + var endpoint: String? = nil + var username: String? = nil + var password: String? = nil + var token: String? = nil + + var fontFamily: FontFamily? = nil + var fontSize: FontSize? = nil + var hasFinishedSetup: Bool = false + var enableTTS: Bool? = nil + var theme: Theme? = nil + var cardLayoutStyle: CardLayoutStyle? = nil + var tagSortOrder: TagSortOrder? = nil + + var urlOpener: UrlOpener? = nil + + var isLoggedIn: Bool { + token != nil && !token!.isEmpty + } + + mutating func setToken(_ newToken: String) { + token = newToken + } +} diff --git a/readeck/Domain/Model/TagSortOrder.swift b/readeck/Domain/Model/TagSortOrder.swift new file mode 100644 index 0000000..920610c --- /dev/null +++ b/readeck/Domain/Model/TagSortOrder.swift @@ -0,0 +1,20 @@ +// +// TagSortOrder.swift +// readeck +// +// Created by Ilyas Hallak +// + +import Foundation + +enum TagSortOrder: String, CaseIterable { + case byCount = "count" + case alphabetically = "alphabetically" + + var displayName: String { + switch self { + case .byCount: return "By usage count" + case .alphabetically: return "Alphabetically" + } + } +} diff --git a/readeck/Domain/Model/UrlOpener.swift b/readeck/Domain/Model/UrlOpener.swift index 59b9b01..23e21e6 100644 --- a/readeck/Domain/Model/UrlOpener.swift +++ b/readeck/Domain/Model/UrlOpener.swift @@ -1,3 +1,10 @@ +// +// UrlOpener.swift +// readeck +// +// Created by Ilyas Hallak on 06.11.25. +// + enum UrlOpener: String, CaseIterable { case inAppBrowser = "inAppBrowser" case defaultBrowser = "defaultBrowser" diff --git a/readeck/Domain/UseCase/SyncTagsUseCase.swift b/readeck/Domain/UseCase/SyncTagsUseCase.swift new file mode 100644 index 0000000..8c04710 --- /dev/null +++ b/readeck/Domain/UseCase/SyncTagsUseCase.swift @@ -0,0 +1,21 @@ +import Foundation + +protocol PSyncTagsUseCase { + func execute() async throws +} + +/// Triggers background synchronization of tags from server to Core Data +/// Uses cache-first strategy - returns immediately after triggering sync +class SyncTagsUseCase: PSyncTagsUseCase { + private let labelsRepository: PLabelsRepository + + init(labelsRepository: PLabelsRepository) { + self.labelsRepository = labelsRepository + } + + func execute() async throws { + // Trigger the sync - getLabels() uses cache-first + background sync strategy + // We don't need the return value, just triggering the sync is enough + _ = try await labelsRepository.getLabels() + } +} diff --git a/readeck/Localizations/de.lproj/Localizable.strings b/readeck/Localizations/de.lproj/Localizable.strings index f2253a1..0b938f9 100644 --- a/readeck/Localizations/de.lproj/Localizable.strings +++ b/readeck/Localizations/de.lproj/Localizable.strings @@ -59,6 +59,9 @@ "Are you sure you want to delete this bookmark? This action cannot be undone." = "Dieses Lesezeichen wirklich lΓΆschen? Diese Aktion kann nicht rΓΌckgΓ€ngig gemacht werden."; "Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Wirklich abmelden? Dies lΓΆscht alle Anmeldedaten und fΓΌhrt zurΓΌck zur Einrichtung."; "Available tags" = "VerfΓΌgbare Labels"; +"Most used tags" = "Meist verwendete Labels"; +"Sorted by usage count" = "Sortiert nach VerwendungshΓ€ufigkeit"; +"Sorted alphabetically" = "Alphabetisch sortiert"; "Cancel" = "Abbrechen"; "Category-specific Levels" = "Kategorie-spezifische Level"; "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Γ„nderungen werden sofort wirksam. Niedrigere Log-Level enthalten hΓΆhere (Debug enthΓ€lt alle, Critical nur kritische Nachrichten)."; diff --git a/readeck/Localizations/en.lproj/Localizable.strings b/readeck/Localizations/en.lproj/Localizable.strings index dfe7fca..67078d4 100644 --- a/readeck/Localizations/en.lproj/Localizable.strings +++ b/readeck/Localizations/en.lproj/Localizable.strings @@ -55,6 +55,9 @@ "Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone."; "Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup."; "Available tags" = "Available tags"; +"Most used tags" = "Most used tags"; +"Sorted by usage count" = "Sorted by usage count"; +"Sorted alphabetically" = "Sorted alphabetically"; "Cancel" = "Cancel"; "Category-specific Levels" = "Category-specific Levels"; "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)."; diff --git a/readeck/UI/AddBookmark/AddBookmarkView.swift b/readeck/UI/AddBookmark/AddBookmarkView.swift index 223243a..65f22f1 100644 --- a/readeck/UI/AddBookmark/AddBookmarkView.swift +++ b/readeck/UI/AddBookmark/AddBookmarkView.swift @@ -4,6 +4,8 @@ import UIKit struct AddBookmarkView: View { @State private var viewModel = AddBookmarkViewModel() @Environment(\.dismiss) private var dismiss + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject private var appSettings: AppSettings @FocusState private var focusedField: AddBookmarkFieldFocus? @State private var keyboardHeight: CGFloat = 0 @@ -58,9 +60,9 @@ struct AddBookmarkView: View { } .onAppear { viewModel.checkClipboard() - } - .task { - await viewModel.loadAllLabels() + Task { + await viewModel.syncTags() + } } .onDisappear { viewModel.clearForm() @@ -177,23 +179,28 @@ struct AddBookmarkView: View { @ViewBuilder private var labelsField: some View { - TagManagementView( - allLabels: viewModel.allLabels, - selectedLabels: viewModel.selectedLabels, - searchText: $viewModel.searchText, - isLabelsLoading: viewModel.isLabelsLoading, - filteredLabels: viewModel.filteredLabels, - searchFieldFocus: $focusedField, - onAddCustomTag: { - viewModel.addCustomTag() - }, - onToggleLabel: { label in - viewModel.toggleLabel(label) - }, - onRemoveLabel: { label in - viewModel.removeLabel(label) - } - ) + VStack(alignment: .leading, spacing: 8) { + Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized) + .font(.caption) + .foregroundColor(.secondary) + + CoreDataTagManagementView( + selectedLabels: viewModel.selectedLabels, + searchText: $viewModel.searchText, + searchFieldFocus: $focusedField, + fetchLimit: nil, + sortOrder: appSettings.tagSortOrder, + onAddCustomTag: { + viewModel.addCustomTag() + }, + onToggleLabel: { label in + viewModel.toggleLabel(label) + }, + onRemoveLabel: { label in + viewModel.removeLabel(label) + } + ) + } } @ViewBuilder diff --git a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift index 6ad8135..01f113a 100644 --- a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift +++ b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift @@ -8,6 +8,7 @@ class AddBookmarkViewModel { private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase() private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase() + private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase() // MARK: - Form Data var url: String = "" @@ -60,12 +61,19 @@ class AddBookmarkViewModel { } // MARK: - Labels Management - + + /// Triggers background sync of tags from server to Core Data + /// CoreDataTagManagementView will automatically update via @FetchRequest + @MainActor + func syncTags() async { + try? await syncTagsUseCase.execute() + } + @MainActor func loadAllLabels() async { isLabelsLoading = true defer { isLabelsLoading = false } - + do { let labels = try await getLabelsUseCase.execute() allLabels = labels.sorted { $0.count > $1.count } diff --git a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift index f72dc3c..27c86b0 100644 --- a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift @@ -4,6 +4,8 @@ struct BookmarkLabelsView: View { let bookmarkId: String @State private var viewModel: BookmarkLabelsViewModel @Environment(\.dismiss) private var dismiss + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject private var appSettings: AppSettings init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) { self.bookmarkId = bookmarkId @@ -40,13 +42,15 @@ struct BookmarkLabelsView: View { } message: { Text(viewModel.errorMessage ?? "Unknown error") } - .task { - await viewModel.loadAllLabels() - } .ignoresSafeArea(.keyboard) .onTapGesture { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } + .onAppear { + Task { + await viewModel.syncTags() + } + } } } @@ -56,29 +60,35 @@ struct BookmarkLabelsView: View { @ViewBuilder private var availableLabelsSection: some View { - TagManagementView( - allLabels: viewModel.allLabels, - selectedLabels: Set(viewModel.currentLabels), - searchText: $viewModel.searchText, - isLabelsLoading: viewModel.isInitialLoading, - filteredLabels: viewModel.filteredLabels, - onAddCustomTag: { - Task { - await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) + VStack(alignment: .leading, spacing: 8) { + Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + + CoreDataTagManagementView( + selectedLabels: Set(viewModel.currentLabels), + searchText: $viewModel.searchText, + fetchLimit: nil, + sortOrder: appSettings.tagSortOrder, + onAddCustomTag: { + Task { + await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) + } + }, + onToggleLabel: { label in + Task { + await viewModel.toggleLabel(for: bookmarkId, label: label) + } + }, + onRemoveLabel: { label in + Task { + await viewModel.removeLabel(from: bookmarkId, label: label) + } } - }, - onToggleLabel: { label in - Task { - await viewModel.toggleLabel(for: bookmarkId, label: label) - } - }, - onRemoveLabel: { label in - Task { - await viewModel.removeLabel(from: bookmarkId, label: label) - } - } - ) - .padding(.horizontal) + ) + .padding(.horizontal) + } } } diff --git a/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift index da4af3d..99968e7 100644 --- a/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift @@ -5,6 +5,7 @@ class BookmarkLabelsViewModel { private let addLabelsUseCase: PAddLabelsToBookmarkUseCase private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase private let getLabelsUseCase: PGetLabelsUseCase + private let syncTagsUseCase: PSyncTagsUseCase var isLoading = false var isInitialLoading = false @@ -30,13 +31,20 @@ class BookmarkLabelsViewModel { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) { self.currentLabels = initialLabels - + self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase() self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase() self.getLabelsUseCase = factory.makeGetLabelsUseCase() - + self.syncTagsUseCase = factory.makeSyncTagsUseCase() } + /// Triggers background sync of tags from server to Core Data + /// CoreDataTagManagementView will automatically update via @FetchRequest + @MainActor + func syncTags() async { + try? await syncTagsUseCase.execute() + } + @MainActor func loadAllLabels() async { isInitialLoading = true diff --git a/readeck/UI/Components/CoreDataTagManagementView.swift b/readeck/UI/Components/CoreDataTagManagementView.swift new file mode 100644 index 0000000..b3fd97d --- /dev/null +++ b/readeck/UI/Components/CoreDataTagManagementView.swift @@ -0,0 +1,255 @@ +import SwiftUI +import CoreData + +struct CoreDataTagManagementView: View { + + // MARK: - Properties + + let selectedLabelsSet: Set + let searchText: Binding + let searchFieldFocus: FocusState.Binding? + let sortOrder: TagSortOrder + let availableTagsTitle: String? + + // MARK: - Callbacks + + let onAddCustomTag: () -> Void + let onToggleLabel: (String) -> Void + let onRemoveLabel: (String) -> Void + + // MARK: - FetchRequest + + @FetchRequest + private var tagEntities: FetchedResults + + // MARK: - Initialization + + init( + selectedLabels: Set, + searchText: Binding, + searchFieldFocus: FocusState.Binding? = nil, + fetchLimit: Int? = nil, + sortOrder: TagSortOrder = .byCount, + availableTagsTitle: String? = nil, + onAddCustomTag: @escaping () -> Void, + onToggleLabel: @escaping (String) -> Void, + onRemoveLabel: @escaping (String) -> Void + ) { + self.selectedLabelsSet = selectedLabels + self.searchText = searchText + self.searchFieldFocus = searchFieldFocus + self.sortOrder = sortOrder + self.availableTagsTitle = availableTagsTitle + self.onAddCustomTag = onAddCustomTag + self.onToggleLabel = onToggleLabel + self.onRemoveLabel = onRemoveLabel + + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + + // Apply sort order from parameter + let sortDescriptors: [NSSortDescriptor] + switch sortOrder { + case .byCount: + sortDescriptors = [ + NSSortDescriptor(keyPath: \TagEntity.count, ascending: false), + NSSortDescriptor(keyPath: \TagEntity.name, ascending: true) + ] + case .alphabetically: + sortDescriptors = [ + NSSortDescriptor(keyPath: \TagEntity.name, ascending: true) + ] + } + fetchRequest.sortDescriptors = sortDescriptors + + if let limit = fetchLimit { + fetchRequest.fetchLimit = limit + } + fetchRequest.fetchBatchSize = 20 + + _tagEntities = FetchRequest( + fetchRequest: fetchRequest, + animation: .default + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + searchField + customTagSuggestion + availableLabels + selectedLabels + } + } + + // MARK: - View Components + + @ViewBuilder + private var searchField: some View { + TextField("Search or add new tag...", text: searchText) + .textFieldStyle(CustomTextFieldStyle()) + .keyboardType(.default) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .onSubmit { + onAddCustomTag() + } + .modifier(FocusModifier(focusBinding: searchFieldFocus, field: .labels)) + } + + @ViewBuilder + private var customTagSuggestion: some View { + if !searchText.wrappedValue.isEmpty && + !allTagNames.contains(where: { $0.lowercased() == searchText.wrappedValue.lowercased() }) && + !selectedLabelsSet.contains(searchText.wrappedValue) { + HStack { + Text("Add new tag:") + .font(.subheadline) + .foregroundColor(.secondary) + Text(searchText.wrappedValue) + .font(.subheadline) + .fontWeight(.medium) + Spacer() + Button(action: onAddCustomTag) { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(.subheadline) + Text("Add") + .font(.subheadline) + .fontWeight(.medium) + } + } + .foregroundColor(.accentColor) + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(10) + } + } + + @ViewBuilder + private var availableLabels: some View { + if !tagEntities.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(searchText.wrappedValue.isEmpty ? (availableTagsTitle ?? "Available tags") : "Search results") + .font(.subheadline) + .fontWeight(.medium) + if !searchText.wrappedValue.isEmpty { + Text("(\(filteredTagsCount) found)") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + + if availableUnselectedTagsCount == 0 { + VStack { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.green) + Text("All tags selected") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } else { + labelsScrollView + } + } + .padding(.top, 8) + } + } + + @ViewBuilder + private var labelsScrollView: some View { + ScrollView(.horizontal, showsIndicators: false) { + LazyHGrid( + rows: [ + GridItem(.fixed(32), spacing: 8), + GridItem(.fixed(32), spacing: 8), + GridItem(.fixed(32), spacing: 8) + ], + alignment: .top, + spacing: 8 + ) { + ForEach(tagEntities) { entity in + if let name = entity.name, shouldShowTag(name) { + UnifiedLabelChip( + label: name, + isSelected: false, + isRemovable: false, + onTap: { + onToggleLabel(name) + } + ) + .fixedSize(horizontal: true, vertical: false) + } + } + } + .frame(height: 120) // 3 rows * 32px + 2 * 8px spacing + .padding(.horizontal) + } + } + + // MARK: - Computed Properties & Helper Functions + + private var allTagNames: [String] { + tagEntities.compactMap { $0.name } + } + + private var filteredTagsCount: Int { + if searchText.wrappedValue.isEmpty { + return tagEntities.count + } else { + return tagEntities.filter { entity in + guard let name = entity.name else { return false } + return name.localizedCaseInsensitiveContains(searchText.wrappedValue) + }.count + } + } + + private var availableUnselectedTagsCount: Int { + tagEntities.filter { entity in + guard let name = entity.name else { return false } + let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue) + let isNotSelected = !selectedLabelsSet.contains(name) + return matchesSearch && isNotSelected + }.count + } + + private func shouldShowTag(_ name: String) -> Bool { + let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue) + let isNotSelected = !selectedLabelsSet.contains(name) + return matchesSearch && isNotSelected + } + + @ViewBuilder + private var selectedLabels: some View { + if !selectedLabelsSet.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Selected tags") + .font(.subheadline) + .fontWeight(.medium) + + FlowLayout(spacing: 8) { + ForEach(selectedLabelsSet.sorted(), id: \.self) { label in + UnifiedLabelChip( + label: label, + isSelected: true, + isRemovable: true, + onTap: { + // No action for selected labels + }, + onRemove: { + onRemoveLabel(label) + } + ) + } + } + } + .padding(.top, 8) + } + } +} diff --git a/readeck/UI/Components/TagManagementView.swift b/readeck/UI/Components/LegacyTagManagementView.swift similarity index 97% rename from readeck/UI/Components/TagManagementView.swift rename to readeck/UI/Components/LegacyTagManagementView.swift index a680faa..f3ded78 100644 --- a/readeck/UI/Components/TagManagementView.swift +++ b/readeck/UI/Components/LegacyTagManagementView.swift @@ -1,3 +1,7 @@ +// TODO: deprecated - This file is no longer used and can be removed +// Replaced by CoreDataTagManagementView.swift which uses Core Data directly +// instead of fetching labels via API + import SwiftUI struct FlowLayout: Layout { @@ -75,7 +79,7 @@ struct FocusModifier: ViewModifier { } } -struct TagManagementView: View { +struct LegacyTagManagementView: View { // MARK: - Properties diff --git a/readeck/UI/Extension/FontSizeExtension.swift b/readeck/UI/Extension/FontSizeExtension.swift new file mode 100644 index 0000000..4cfc66c --- /dev/null +++ b/readeck/UI/Extension/FontSizeExtension.swift @@ -0,0 +1,14 @@ +// +// FontSizeExtension.swift +// readeck +// +// Created by Ilyas Hallak on 06.11.25. +// + +import SwiftUI + +extension FontSize { + var systemFont: Font { + return Font.system(size: size) + } +} diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index 34798d7..dd23504 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -16,6 +16,7 @@ protocol UseCaseFactory { func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase func makeGetLabelsUseCase() -> PGetLabelsUseCase + func makeSyncTagsUseCase() -> PSyncTagsUseCase func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase @@ -102,7 +103,13 @@ class DefaultUseCaseFactory: UseCaseFactory { let labelsRepository = LabelsRepository(api: api) return GetLabelsUseCase(labelsRepository: labelsRepository) } - + + func makeSyncTagsUseCase() -> PSyncTagsUseCase { + let api = API(tokenProvider: KeychainTokenProvider()) + let labelsRepository = LabelsRepository(api: api) + return SyncTagsUseCase(labelsRepository: labelsRepository) + } + func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase { return AddTextToSpeechQueueUseCase() } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index fb83faf..a86aba6 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -76,7 +76,11 @@ class MockUseCaseFactory: UseCaseFactory { func makeGetLabelsUseCase() -> any PGetLabelsUseCase { MockGetLabelsUseCase() } - + + func makeSyncTagsUseCase() -> any PSyncTagsUseCase { + MockSyncTagsUseCase() + } + func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase { MockAddTextToSpeechQueueUseCase() } @@ -125,6 +129,12 @@ class MockGetLabelsUseCase: PGetLabelsUseCase { } } +class MockSyncTagsUseCase: PSyncTagsUseCase { + func execute() async throws { + // Mock implementation - does nothing + } +} + class MockSearchBookmarksUseCase: PSearchBookmarksUseCase { func execute(search: String) async throws -> BookmarksPage { BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil) diff --git a/readeck/UI/Models/AppSettings.swift b/readeck/UI/Models/AppSettings.swift index 4ab7c18..c09d8ff 100644 --- a/readeck/UI/Models/AppSettings.swift +++ b/readeck/UI/Models/AppSettings.swift @@ -18,19 +18,23 @@ import Combine class AppSettings: ObservableObject { @Published var settings: Settings? - + var enableTTS: Bool { settings?.enableTTS ?? false } - + var theme: Theme { settings?.theme ?? .system } - + var urlOpener: UrlOpener { settings?.urlOpener ?? .inAppBrowser } + var tagSortOrder: TagSortOrder { + settings?.tagSortOrder ?? .byCount + } + init(settings: Settings? = nil) { self.settings = settings } diff --git a/readeck/UI/Settings/AppearanceSettingsView.swift b/readeck/UI/Settings/AppearanceSettingsView.swift index c5780c5..a423b69 100644 --- a/readeck/UI/Settings/AppearanceSettingsView.swift +++ b/readeck/UI/Settings/AppearanceSettingsView.swift @@ -3,9 +3,12 @@ import SwiftUI struct AppearanceSettingsView: View { @State private var selectedCardLayout: CardLayoutStyle = .magazine @State private var selectedTheme: Theme = .system + @State private var selectedTagSortOrder: TagSortOrder = .byCount @State private var fontViewModel: FontSettingsViewModel @State private var generalViewModel: SettingsGeneralViewModel + @EnvironmentObject private var appSettings: AppSettings + private let loadCardLayoutUseCase: PLoadCardLayoutUseCase private let saveCardLayoutUseCase: PSaveCardLayoutUseCase private let settingsRepository: PSettingsRepository @@ -104,10 +107,20 @@ struct AppearanceSettingsView: View { await generalViewModel.saveGeneralSettings() } } + + // Tag Sort Order + Picker("Tag sort order", selection: $selectedTagSortOrder) { + ForEach(TagSortOrder.allCases, id: \.self) { sortOrder in + Text(sortOrder.displayName).tag(sortOrder) + } + } + .onChange(of: selectedTagSortOrder) { + saveTagSortOrderSettings() + } } header: { Text("Appearance") } footer: { - Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.") + Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.\n\nTag sort order determines how tags are displayed when adding or editing bookmarks.") } } .task { @@ -119,10 +132,11 @@ struct AppearanceSettingsView: View { private func loadSettings() { Task { - // Load both theme and card layout from repository + // Load theme, card layout, and tag sort order from repository if let settings = try? await settingsRepository.loadSettings() { await MainActor.run { selectedTheme = settings.theme ?? .system + selectedTagSortOrder = settings.tagSortOrder ?? .byCount } } selectedCardLayout = await loadCardLayoutUseCase.execute() @@ -152,6 +166,20 @@ struct AppearanceSettingsView: View { } } } + + private func saveTagSortOrderSettings() { + Task { + var settings = (try? await settingsRepository.loadSettings()) ?? Settings() + settings.tagSortOrder = selectedTagSortOrder + try? await settingsRepository.saveSettings(settings) + + // Update AppSettings to trigger UI updates + await MainActor.run { + appSettings.settings?.tagSortOrder = selectedTagSortOrder + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + } + } } #Preview { diff --git a/readeck/UI/Settings/FontSettingsViewModel.swift b/readeck/UI/Settings/FontSettingsViewModel.swift index 374c1dc..0b313df 100644 --- a/readeck/UI/Settings/FontSettingsViewModel.swift +++ b/readeck/UI/Settings/FontSettingsViewModel.swift @@ -99,48 +99,6 @@ class FontSettingsViewModel { } } -// MARK: - Font Enums (moved from SettingsViewModel) -enum FontFamily: String, CaseIterable { - case system = "system" - case serif = "serif" - case sansSerif = "sansSerif" - case monospace = "monospace" - - var displayName: String { - switch self { - case .system: return "System" - case .serif: return "Serif" - case .sansSerif: return "Sans Serif" - case .monospace: return "Monospace" - } - } -} -enum FontSize: String, CaseIterable { - case small = "small" - case medium = "medium" - case large = "large" - case extraLarge = "extraLarge" - - var displayName: String { - switch self { - case .small: return "S" - case .medium: return "M" - case .large: return "L" - case .extraLarge: return "XL" - } - } - - var size: CGFloat { - switch self { - case .small: return 14 - case .medium: return 16 - case .large: return 18 - case .extraLarge: return 20 - } - } - - var systemFont: Font { - return Font.system(size: size) - } -} + + diff --git a/readeck/UI/Utils/NotificationNames.swift b/readeck/UI/Utils/NotificationNames.swift index d3f7716..c420aa8 100644 --- a/readeck/UI/Utils/NotificationNames.swift +++ b/readeck/UI/Utils/NotificationNames.swift @@ -14,4 +14,5 @@ extension Notification.Name { // MARK: - User Preferences static let cardLayoutChanged = Notification.Name("cardLayoutChanged") + static let tagSortOrderChanged = Notification.Name("tagSortOrderChanged") } \ No newline at end of file diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 7723755..61292a1 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -25,6 +25,7 @@ struct readeckApp: App { } } .environmentObject(appSettings) + .environment(\.managedObjectContext, CoreDataManager.shared.context) .preferredColorScheme(appSettings.theme.colorScheme) .onAppear { #if DEBUG diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index d371f92..37064bb 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -55,11 +55,13 @@ + + \ No newline at end of file From 4b788650b89f73f243e4bacb17b753cc3fcaecc7 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 8 Nov 2025 19:12:08 +0100 Subject: [PATCH 35/39] Redesign settings screen with native iOS style - Move font settings to dedicated detail screen with larger preview - Add inline explanations directly under each setting - Reorganize sections: split General into Reading Settings and Sync Settings - Combine Legal, Privacy and Support into single section - Move "What's New" to combined Legal/Privacy/Support section - Redesign app info footer with muted styling and center alignment - Remove white backgrounds from font preview for better light/dark mode support --- readeck.xcodeproj/project.pbxproj | 8 +- readeck/UI/Resources/RELEASE_NOTES.md | 9 ++ .../UI/Settings/AppearanceSettingsView.swift | 91 ++++++--------- readeck/UI/Settings/FontSelectionView.swift | 105 ++++++++++++++++++ .../Settings/LegalPrivacySettingsView.swift | 25 ++++- readeck/UI/Settings/ReadingSettingsView.swift | 55 +++++++++ .../UI/Settings/SettingsContainerView.swift | 25 +++-- readeck/UI/Settings/SyncSettingsView.swift | 64 +++++++++++ 8 files changed, 308 insertions(+), 74 deletions(-) create mode 100644 readeck/UI/Settings/FontSelectionView.swift create mode 100644 readeck/UI/Settings/ReadingSettingsView.swift create mode 100644 readeck/UI/Settings/SyncSettingsView.swift diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 7e456a5..8ec5892 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -452,7 +452,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 34; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -485,7 +485,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 34; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -640,7 +640,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 34; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -684,7 +684,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 34; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; diff --git a/readeck/UI/Resources/RELEASE_NOTES.md b/readeck/UI/Resources/RELEASE_NOTES.md index 559c38f..738cfeb 100644 --- a/readeck/UI/Resources/RELEASE_NOTES.md +++ b/readeck/UI/Resources/RELEASE_NOTES.md @@ -23,6 +23,15 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi - Better performance when working with many labels - Improved overall app stability +### Settings Redesign + +- **Completely redesigned settings screen** with native iOS style +- Font settings moved to dedicated screen with larger preview +- Reorganized sections for better overview +- Inline explanations directly under settings +- Cleaner app info footer with muted styling +- Combined legal, privacy and support into one section + ### Fixes & Improvements - Better color consistency throughout the app diff --git a/readeck/UI/Settings/AppearanceSettingsView.swift b/readeck/UI/Settings/AppearanceSettingsView.swift index a423b69..ce09698 100644 --- a/readeck/UI/Settings/AppearanceSettingsView.swift +++ b/readeck/UI/Settings/AppearanceSettingsView.swift @@ -28,48 +28,17 @@ struct AppearanceSettingsView: View { var body: some View { Group { Section { - // Font Family - Picker("Font family", selection: $fontViewModel.selectedFontFamily) { - ForEach(FontFamily.allCases, id: \.self) { family in - Text(family.displayName).tag(family) + // Font Settings als NavigationLink + NavigationLink { + FontSelectionView(viewModel: fontViewModel) + } label: { + HStack { + Text("Font") + Spacer() + Text("\(fontViewModel.selectedFontFamily.displayName) Β· \(fontViewModel.selectedFontSize.displayName)") + .foregroundColor(.secondary) } } - .onChange(of: fontViewModel.selectedFontFamily) { - Task { - await fontViewModel.saveFontSettings() - } - } - - // Font Size - Picker("Font size", selection: $fontViewModel.selectedFontSize) { - ForEach(FontSize.allCases, id: \.self) { size in - Text(size.displayName).tag(size) - } - } - .pickerStyle(.segmented) - .onChange(of: fontViewModel.selectedFontSize) { - Task { - await fontViewModel.saveFontSettings() - } - } - - // Font Preview - direkt in der gleichen Section - VStack(alignment: .leading, spacing: 6) { - Text("readeck Bookmark Title") - .font(fontViewModel.previewTitleFont) - .fontWeight(.semibold) - .lineLimit(1) - - Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.") - .font(fontViewModel.previewBodyFont) - .lineLimit(3) - - Text("12 min β€’ Today β€’ example.com") - .font(fontViewModel.previewCaptionFont) - .foregroundColor(.secondary) - } - .padding(.vertical, 4) - .listRowBackground(Color(.systemGray6)) // Theme Picker (Menu statt Segmented) Picker("Theme", selection: $selectedTheme) { @@ -97,30 +66,42 @@ struct AppearanceSettingsView: View { } // Open external links in - Picker("Open links in", selection: $generalViewModel.urlOpener) { - ForEach(UrlOpener.allCases, id: \.self) { urlOpener in - Text(urlOpener.displayName).tag(urlOpener) + VStack(alignment: .leading, spacing: 4) { + Picker("Open links in", selection: $generalViewModel.urlOpener) { + ForEach(UrlOpener.allCases, id: \.self) { urlOpener in + Text(urlOpener.displayName).tag(urlOpener) + } } - } - .onChange(of: generalViewModel.urlOpener) { - Task { - await generalViewModel.saveGeneralSettings() + .onChange(of: generalViewModel.urlOpener) { + Task { + await generalViewModel.saveGeneralSettings() + } } + + Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 2) } // Tag Sort Order - Picker("Tag sort order", selection: $selectedTagSortOrder) { - ForEach(TagSortOrder.allCases, id: \.self) { sortOrder in - Text(sortOrder.displayName).tag(sortOrder) + VStack(alignment: .leading, spacing: 4) { + Picker("Tag sort order", selection: $selectedTagSortOrder) { + ForEach(TagSortOrder.allCases, id: \.self) { sortOrder in + Text(sortOrder.displayName).tag(sortOrder) + } } - } - .onChange(of: selectedTagSortOrder) { - saveTagSortOrderSettings() + .onChange(of: selectedTagSortOrder) { + saveTagSortOrderSettings() + } + + Text("Determines how tags are displayed when adding or editing bookmarks.") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 2) } } header: { Text("Appearance") - } footer: { - Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.\n\nTag sort order determines how tags are displayed when adding or editing bookmarks.") } } .task { diff --git a/readeck/UI/Settings/FontSelectionView.swift b/readeck/UI/Settings/FontSelectionView.swift new file mode 100644 index 0000000..b6df041 --- /dev/null +++ b/readeck/UI/Settings/FontSelectionView.swift @@ -0,0 +1,105 @@ +// +// FontSelectionView.swift +// readeck +// +// Created by Ilyas Hallak on 08.11.25. +// + +import SwiftUI + +struct FontSelectionView: View { + @State private var viewModel: FontSettingsViewModel + @Environment(\.dismiss) private var dismiss + + init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) { + self.viewModel = viewModel + } + + var body: some View { + List { + // Preview Section + Section { + VStack(alignment: .leading, spacing: 12) { + Text("readeck Bookmark Title") + .font(viewModel.previewTitleFont) + .fontWeight(.semibold) + .lineLimit(2) + + Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet, consectetur adipiscing elit.") + .font(viewModel.previewBodyFont) + .lineLimit(4) + + Text("12 min β€’ Today β€’ example.com") + .font(viewModel.previewCaptionFont) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: Color.black.opacity(0.08), radius: 8, x: 0, y: 2) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .listRowBackground(Color.clear) + } header: { + Text("Preview") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .textCase(nil) + } + + // Font Settings Section + Section { + Picker("Font family", selection: $viewModel.selectedFontFamily) { + ForEach(FontFamily.allCases, id: \.self) { family in + Text(family.displayName).tag(family) + } + } + .onChange(of: viewModel.selectedFontFamily) { + Task { + await viewModel.saveFontSettings() + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Font size") + .font(.subheadline) + .foregroundColor(.primary) + + Picker("Font size", selection: $viewModel.selectedFontSize) { + ForEach(FontSize.allCases, id: \.self) { size in + Text(size.displayName).tag(size) + } + } + .pickerStyle(.segmented) + .onChange(of: viewModel.selectedFontSize) { + Task { + await viewModel.saveFontSettings() + } + } + } + .padding(.vertical, 4) + } header: { + Text("Font Settings") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .textCase(nil) + } + } + .listStyle(.insetGrouped) + .navigationTitle("Font") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadFontSettings() + } + } +} + +#Preview { + NavigationStack { + FontSelectionView(viewModel: .init( + factory: MockUseCaseFactory() + )) + } +} diff --git a/readeck/UI/Settings/LegalPrivacySettingsView.swift b/readeck/UI/Settings/LegalPrivacySettingsView.swift index aae51df..7c0b35e 100644 --- a/readeck/UI/Settings/LegalPrivacySettingsView.swift +++ b/readeck/UI/Settings/LegalPrivacySettingsView.swift @@ -3,10 +3,26 @@ import SwiftUI struct LegalPrivacySettingsView: View { @State private var showingPrivacyPolicy = false @State private var showingLegalNotice = false + @State private var showReleaseNotes = false var body: some View { Group { Section { + Button(action: { + showReleaseNotes = true + }) { + HStack { + Text("What's New") + Spacer() + Text("Version \(VersionManager.shared.currentVersion)") + .font(.caption) + .foregroundColor(.secondary) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + Button(action: { showingPrivacyPolicy = true }) { @@ -30,11 +46,7 @@ struct LegalPrivacySettingsView: View { .foregroundColor(.secondary) } } - } header: { - Text("Legal & Privacy") - } - Section { Button(action: { if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") { UIApplication.shared.open(url) @@ -63,7 +75,7 @@ struct LegalPrivacySettingsView: View { } } } header: { - Text("Support") + Text("Legal, Privacy & Support") } } .sheet(isPresented: $showingPrivacyPolicy) { @@ -72,6 +84,9 @@ struct LegalPrivacySettingsView: View { .sheet(isPresented: $showingLegalNotice) { LegalNoticeView() } + .sheet(isPresented: $showReleaseNotes) { + ReleaseNotesView() + } } } diff --git a/readeck/UI/Settings/ReadingSettingsView.swift b/readeck/UI/Settings/ReadingSettingsView.swift new file mode 100644 index 0000000..1cb03c2 --- /dev/null +++ b/readeck/UI/Settings/ReadingSettingsView.swift @@ -0,0 +1,55 @@ +// +// ReadingSettingsView.swift +// readeck +// +// Created by Ilyas Hallak on 08.11.25. +// + +import SwiftUI + +struct ReadingSettingsView: View { + @State private var viewModel: SettingsGeneralViewModel + + init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) { + self.viewModel = viewModel + } + + var body: some View { + Group { + Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS) + .onChange(of: viewModel.enableTTS) { + Task { + await viewModel.saveGeneralSettings() + } + } + + Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 2) + } + + #if DEBUG + Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode) + Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead) + #endif + } header: { + Text("Reading Settings") + } + } + .task { + await viewModel.loadGeneralSettings() + } + } +} + +#Preview { + List { + ReadingSettingsView(viewModel: .init( + MockUseCaseFactory() + )) + } + .listStyle(.insetGrouped) +} diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift index 01425ea..f466ee8 100644 --- a/readeck/UI/Settings/SettingsContainerView.swift +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -19,9 +19,11 @@ struct SettingsContainerView: View { List { AppearanceSettingsView() + ReadingSettingsView() + CacheSettingsView() - SettingsGeneralView() + SyncSettingsView() SettingsServerView() @@ -80,39 +82,42 @@ struct SettingsContainerView: View { @ViewBuilder private var appInfoSection: some View { Section { - VStack(spacing: 8) { - HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { Image(systemName: "info.circle") + .font(.caption) .foregroundColor(.secondary) Text("Version \(appVersion)") - .font(.footnote) + .font(.caption) .foregroundColor(.secondary) } HStack(spacing: 4) { Image(systemName: "person.crop.circle") + .font(.caption) .foregroundColor(.secondary) Text("Developer:") - .font(.footnote) + .font(.caption) .foregroundColor(.secondary) Button("Ilyas Hallak") { if let url = URL(string: "https://ilyashallak.de") { UIApplication.shared.open(url) } } - .font(.footnote) - .foregroundColor(.blue) + .font(.caption) } - HStack(spacing: 8) { + HStack(spacing: 6) { Image(systemName: "globe") + .font(.caption) .foregroundColor(.secondary) Text("From Bremen with πŸ’š") - .font(.footnote) + .font(.caption) .foregroundColor(.secondary) } } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) .padding(.vertical, 8) } } diff --git a/readeck/UI/Settings/SyncSettingsView.swift b/readeck/UI/Settings/SyncSettingsView.swift new file mode 100644 index 0000000..f8a49df --- /dev/null +++ b/readeck/UI/Settings/SyncSettingsView.swift @@ -0,0 +1,64 @@ +// +// SyncSettingsView.swift +// readeck +// +// Created by Ilyas Hallak on 08.11.25. +// + +import SwiftUI + +struct SyncSettingsView: View { + @State private var viewModel: SettingsGeneralViewModel + + init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) { + self.viewModel = viewModel + } + + var body: some View { + Group { + #if DEBUG + Section { + Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled) + if viewModel.autoSyncEnabled { + Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60) + } + } header: { + Text("Sync Settings") + } + + if let successMessage = viewModel.successMessage { + Section { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(successMessage) + .foregroundColor(.green) + } + } + } + if let errorMessage = viewModel.errorMessage { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + } + } + } + #endif + } + .task { + await viewModel.loadGeneralSettings() + } + } +} + +#Preview { + List { + SyncSettingsView(viewModel: .init( + MockUseCaseFactory() + )) + } + .listStyle(.insetGrouped) +} From e5d4e6d8a07d00bb40449b265d23c5e155c43390 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 8 Nov 2025 19:18:41 +0100 Subject: [PATCH 36/39] Fix tag scrolling and improve debug logging - Fix duplicate ID warning in CoreDataTagManagementView by using objectID - Enhance debug logging system with category filtering --- readeck/UI/Resources/RELEASE_NOTES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/readeck/UI/Resources/RELEASE_NOTES.md b/readeck/UI/Resources/RELEASE_NOTES.md index 738cfeb..767653a 100644 --- a/readeck/UI/Resources/RELEASE_NOTES.md +++ b/readeck/UI/Resources/RELEASE_NOTES.md @@ -32,6 +32,16 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi - Cleaner app info footer with muted styling - Combined legal, privacy and support into one section +### Tag Management Improvements + +- **Handles 1000+ tags smoothly** - no more lag or slowdowns +- **Tags now load from local database** - no internet required +- Choose your preferred tag sorting: by usage count or alphabetically +- Tags sync automatically in the background +- Share Extension shows your 150 most-used tags instantly +- Better offline support for managing tags +- Faster and more responsive tag selection + ### Fixes & Improvements - Better color consistency throughout the app From 4134b41be227d4947fed7c89dc4f2d48984f9e9c Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 8 Nov 2025 19:18:56 +0100 Subject: [PATCH 37/39] Fix tag scrolling and improve debug logging - Fix duplicate ID warning in CoreDataTagManagementView by using objectID - Enhance debug logging system with category filtering --- .../CoreDataTagManagementView.swift | 2 +- readeck/Utils/Logger.swift | 33 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/readeck/UI/Components/CoreDataTagManagementView.swift b/readeck/UI/Components/CoreDataTagManagementView.swift index b3fd97d..24f630b 100644 --- a/readeck/UI/Components/CoreDataTagManagementView.swift +++ b/readeck/UI/Components/CoreDataTagManagementView.swift @@ -174,7 +174,7 @@ struct CoreDataTagManagementView: View { alignment: .top, spacing: 8 ) { - ForEach(tagEntities) { entity in + ForEach(tagEntities, id: \.objectID) { entity in if let name = entity.name, shouldShowTag(name) { UnifiedLabelChip( label: name, diff --git a/readeck/Utils/Logger.swift b/readeck/Utils/Logger.swift index 9f1feb1..16873af 100644 --- a/readeck/Utils/Logger.swift +++ b/readeck/Utils/Logger.swift @@ -52,6 +52,19 @@ class LogConfiguration: ObservableObject { @Published var isLoggingEnabled = false private init() { + // First time setup: Enable logging in DEBUG builds with sensible defaults + #if DEBUG + if UserDefaults.standard.object(forKey: "LogConfigurationInitialized") == nil { + isLoggingEnabled = true + showPerformanceLogs = true + showTimestamps = true + includeSourceLocation = true + globalMinLevel = .debug + UserDefaults.standard.set(true, forKey: "LogConfigurationInitialized") + saveConfiguration() + } + #endif + loadConfiguration() } @@ -81,12 +94,22 @@ class LogConfiguration: ObservableObject { } } } - + 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") - isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled") + + // Load boolean settings with defaults + if UserDefaults.standard.object(forKey: "LogShowPerformance") != nil { + showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance") + } + if UserDefaults.standard.object(forKey: "LogShowTimestamps") != nil { + showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps") + } + if UserDefaults.standard.object(forKey: "LogIncludeSourceLocation") != nil { + includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation") + } + if UserDefaults.standard.object(forKey: "LogIsEnabled") != nil { + isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled") + } } private func saveConfiguration() { From a3b3863fa3ecd2ae3d831850480824f4311462c8 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Mon, 10 Nov 2025 21:29:38 +0100 Subject: [PATCH 38/39] Refactor tag management system with improved search and real-time sync - Add CreateLabelUseCase for consistent label creation across app and extension - Implement TagRepository for Share Extension to persist new labels to Core Data - Enhance CoreDataTagManagementView with real-time search functionality - Add automatic tag synchronization on app startup and resume - Improve Core Data context configuration for better extension support - Unify label terminology across UI components (tags -> labels) - Fix label persistence issues in Share Extension - Add immediate Core Data persistence for newly created labels - Bump version to 36 --- URLShare/ShareBookmarkView.swift | 18 +-- URLShare/ShareBookmarkViewModel.swift | 28 +++- URLShare/TagRepository.swift | 63 ++++++++ readeck.xcodeproj/project.pbxproj | 8 +- readeck/Data/CoreData/CoreDataManager.swift | 5 + .../Data/Repository/LabelsRepository.swift | 25 +++ .../Domain/Protocols/PLabelsRepository.swift | 1 + .../Domain/UseCase/CreateLabelUseCase.swift | 17 +++ readeck/UI/AddBookmark/AddBookmarkView.swift | 1 + .../UI/AddBookmark/AddBookmarkViewModel.swift | 10 +- readeck/UI/AppViewModel.swift | 21 +++ .../BookmarkDetail/BookmarkLabelsView.swift | 1 + .../CoreDataTagManagementView.swift | 143 +++++++++++++----- .../UI/Factory/DefaultUseCaseFactory.swift | 7 + readeck/UI/Factory/MockUseCaseFactory.swift | 10 ++ 15 files changed, 301 insertions(+), 57 deletions(-) create mode 100644 URLShare/TagRepository.swift create mode 100644 readeck/Domain/UseCase/CreateLabelUseCase.swift diff --git a/URLShare/ShareBookmarkView.swift b/URLShare/ShareBookmarkView.swift index 5778a21..933431e 100644 --- a/URLShare/ShareBookmarkView.swift +++ b/URLShare/ShareBookmarkView.swift @@ -142,7 +142,8 @@ struct ShareBookmarkView: View { searchFieldFocus: $focusedField, fetchLimit: 150, sortOrder: viewModel.tagSortOrder, - availableTagsTitle: "Most used tags", + availableLabelsTitle: "Most used labels", + context: viewContext, onAddCustomTag: { addCustomTag() }, @@ -200,19 +201,6 @@ struct ShareBookmarkView: View { // MARK: - Helper Functions private func addCustomTag() { - let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText) - - // Fetch available labels from Core Data - let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() - let availableLabels = (try? viewContext.fetch(fetchRequest))?.compactMap { $0.name } ?? [] - - let currentLabels = Array(viewModel.selectedLabels) - let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels) - - for label in uniqueLabels { - viewModel.selectedLabels.insert(label) - } - - viewModel.searchText = "" + viewModel.addCustomTag(context: viewContext) } } diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index 66941a8..1d22ad5 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -16,6 +16,7 @@ class ShareBookmarkViewModel: ObservableObject { private let logger = Logger.viewModel private let serverCheck = ShareExtensionServerCheck.shared + private let tagRepository = TagRepository() init(extensionContext: NSExtensionContext?) { self.extensionContext = extensionContext @@ -149,14 +150,37 @@ class ShareBookmarkViewModel: ObservableObject { } } } - + + func addCustomTag(context: NSManagedObjectContext) { + let splitLabels = LabelUtils.splitLabelsFromInput(searchText) + + // Fetch available labels from Core Data + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + let availableLabels = (try? context.fetch(fetchRequest))?.compactMap { $0.name } ?? [] + + let currentLabels = Array(selectedLabels) + let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels) + + for label in uniqueLabels { + selectedLabels.insert(label) + // Save new label to Core Data so it's available next time + tagRepository.saveNewLabel(name: label, context: context) + } + + // Force refresh of @FetchRequest in CoreDataTagManagementView + // This ensures newly created labels appear immediately in the search results + context.refreshAllObjects() + + searchText = "" + } + private func completeExtensionRequest() { logger.debug("Completing extension request") guard let context = extensionContext else { logger.warning("Extension context not available for completion") return } - + context.completeRequest(returningItems: []) { [weak self] error in if error { self?.logger.error("Extension completion failed: \(error)") diff --git a/URLShare/TagRepository.swift b/URLShare/TagRepository.swift new file mode 100644 index 0000000..26b2e8d --- /dev/null +++ b/URLShare/TagRepository.swift @@ -0,0 +1,63 @@ +import Foundation +import CoreData + +/// Simple repository for managing tags in Share Extension +class TagRepository { + + private let logger = Logger.data + + /// Saves a new label to Core Data if it doesn't already exist + /// - Parameters: + /// - name: The label name to save + /// - context: The managed object context to use + func saveNewLabel(name: String, context: NSManagedObjectContext) { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + + // Perform save in a synchronous block to ensure it completes before extension closes + context.performAndWait { + // Check if label already exists + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName) + fetchRequest.fetchLimit = 1 + + do { + let existingTags = try context.fetch(fetchRequest) + + // Only create if it doesn't exist + if existingTags.isEmpty { + let newTag = TagEntity(context: context) + newTag.name = trimmedName + newTag.count = 1 // New label is being used immediately + + try context.save() + logger.info("Successfully saved new label '\(trimmedName)' to Core Data") + + // Force immediate persistence to disk for share extension + // Based on: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/ + // 1. Process pending changes + context.processPendingChanges() + + // 2. Ensure persistent store coordinator writes to disk + // This is critical for extensions as they may be terminated quickly + if context.persistentStoreCoordinator != nil { + // Refresh all objects to ensure changes are pushed to store + context.refreshAllObjects() + + // Reset staleness interval temporarily to force immediate persistence + let originalStalenessInterval = context.stalenessInterval + context.stalenessInterval = 0 + context.refreshAllObjects() + context.stalenessInterval = originalStalenessInterval + + logger.debug("Forced context refresh to ensure persistence") + } + } else { + logger.debug("Label '\(trimmedName)' already exists, skipping creation") + } + } catch { + logger.error("Failed to save new label '\(trimmedName)': \(error.localizedDescription)") + } + } + } +} diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 8ec5892..ba2ec86 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -452,7 +452,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -485,7 +485,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -640,7 +640,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -684,7 +684,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; diff --git a/readeck/Data/CoreData/CoreDataManager.swift b/readeck/Data/CoreData/CoreDataManager.swift index b5b6593..7bb0fa0 100644 --- a/readeck/Data/CoreData/CoreDataManager.swift +++ b/readeck/Data/CoreData/CoreDataManager.swift @@ -43,6 +43,11 @@ class CoreDataManager { self?.logger.info("Core Data persistent store loaded successfully") } } + + // Configure viewContext for better extension support + container.viewContext.automaticallyMergesChangesFromParent = true + container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + return container }() diff --git a/readeck/Data/Repository/LabelsRepository.swift b/readeck/Data/Repository/LabelsRepository.swift index 989e433..4b91528 100644 --- a/readeck/Data/Repository/LabelsRepository.swift +++ b/readeck/Data/Repository/LabelsRepository.swift @@ -89,4 +89,29 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable { } } } + + func saveNewLabel(name: String) async throws { + let backgroundContext = coreDataManager.newBackgroundContext() + + try await backgroundContext.perform { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + + // Check if label already exists + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName) + fetchRequest.fetchLimit = 1 + + let existingTags = try backgroundContext.fetch(fetchRequest) + + // Only create if it doesn't exist + if existingTags.isEmpty { + let newTag = TagEntity(context: backgroundContext) + newTag.name = trimmedName + newTag.count = 1 // New label is being used immediately + + try backgroundContext.save() + } + } + } } diff --git a/readeck/Domain/Protocols/PLabelsRepository.swift b/readeck/Domain/Protocols/PLabelsRepository.swift index 4ad6cf6..f33863a 100644 --- a/readeck/Domain/Protocols/PLabelsRepository.swift +++ b/readeck/Domain/Protocols/PLabelsRepository.swift @@ -3,4 +3,5 @@ import Foundation protocol PLabelsRepository { func getLabels() async throws -> [BookmarkLabel] func saveLabels(_ dtos: [BookmarkLabelDto]) async throws + func saveNewLabel(name: String) async throws } diff --git a/readeck/Domain/UseCase/CreateLabelUseCase.swift b/readeck/Domain/UseCase/CreateLabelUseCase.swift new file mode 100644 index 0000000..722f444 --- /dev/null +++ b/readeck/Domain/UseCase/CreateLabelUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol PCreateLabelUseCase { + func execute(name: String) async throws +} + +class CreateLabelUseCase: PCreateLabelUseCase { + private let labelsRepository: PLabelsRepository + + init(labelsRepository: PLabelsRepository) { + self.labelsRepository = labelsRepository + } + + func execute(name: String) async throws { + try await labelsRepository.saveNewLabel(name: name) + } +} diff --git a/readeck/UI/AddBookmark/AddBookmarkView.swift b/readeck/UI/AddBookmark/AddBookmarkView.swift index 65f22f1..f9da7a5 100644 --- a/readeck/UI/AddBookmark/AddBookmarkView.swift +++ b/readeck/UI/AddBookmark/AddBookmarkView.swift @@ -190,6 +190,7 @@ struct AddBookmarkView: View { searchFieldFocus: $focusedField, fetchLimit: nil, sortOrder: appSettings.tagSortOrder, + context: viewContext, onAddCustomTag: { viewModel.addCustomTag() }, diff --git a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift index 01f113a..5e4a2d8 100644 --- a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift +++ b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift @@ -8,6 +8,7 @@ class AddBookmarkViewModel { private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase() private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase() + private let createLabelUseCase = DefaultUseCaseFactory.shared.makeCreateLabelUseCase() private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase() // MARK: - Form Data @@ -87,17 +88,22 @@ class AddBookmarkViewModel { func addCustomTag() { let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - + let lowercased = trimmed.lowercased() let allExisting = Set(allLabels.map { $0.name.lowercased() }) let allSelected = Set(selectedLabels.map { $0.lowercased() }) - + if allExisting.contains(lowercased) || allSelected.contains(lowercased) { // Tag already exists, don't add return } else { selectedLabels.insert(trimmed) searchText = "" + + // Save new label to Core Data so it's available next time + Task { + try? await createLabelUseCase.execute(name: trimmed) + } } } diff --git a/readeck/UI/AppViewModel.swift b/readeck/UI/AppViewModel.swift index cf97bc9..43f3277 100644 --- a/readeck/UI/AppViewModel.swift +++ b/readeck/UI/AppViewModel.swift @@ -13,12 +13,16 @@ import SwiftUI class AppViewModel { private let settingsRepository = SettingsRepository() private let factory: UseCaseFactory + private let syncTagsUseCase: PSyncTagsUseCase var hasFinishedSetup: Bool = true var isServerReachable: Bool = false + private var lastAppStartTagSyncTime: Date? + init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) { self.factory = factory + self.syncTagsUseCase = factory.makeSyncTagsUseCase() setupNotificationObservers() loadSetupStatus() @@ -65,12 +69,29 @@ class AppViewModel { func onAppResume() async { await checkServerReachability() + await syncTagsOnAppStart() } private func checkServerReachability() async { isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute() } + private func syncTagsOnAppStart() async { + let now = Date() + + // Check if last sync was less than 2 minutes ago + if let lastSync = lastAppStartTagSyncTime, + now.timeIntervalSince(lastSync) < 120 { + print("AppViewModel: Skipping tag sync - last sync was less than 2 minutes ago") + return + } + + // Sync tags from server to Core Data + print("AppViewModel: Syncing tags on app start") + try? await syncTagsUseCase.execute() + lastAppStartTagSyncTime = now + } + deinit { NotificationCenter.default.removeObserver(self) } diff --git a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift index 27c86b0..6e1bb01 100644 --- a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift @@ -71,6 +71,7 @@ struct BookmarkLabelsView: View { searchText: $viewModel.searchText, fetchLimit: nil, sortOrder: appSettings.tagSortOrder, + context: viewContext, onAddCustomTag: { Task { await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) diff --git a/readeck/UI/Components/CoreDataTagManagementView.swift b/readeck/UI/Components/CoreDataTagManagementView.swift index 24f630b..665dc4f 100644 --- a/readeck/UI/Components/CoreDataTagManagementView.swift +++ b/readeck/UI/Components/CoreDataTagManagementView.swift @@ -9,7 +9,8 @@ struct CoreDataTagManagementView: View { let searchText: Binding let searchFieldFocus: FocusState.Binding? let sortOrder: TagSortOrder - let availableTagsTitle: String? + let availableLabelsTitle: String? + let context: NSManagedObjectContext // MARK: - Callbacks @@ -22,6 +23,11 @@ struct CoreDataTagManagementView: View { @FetchRequest private var tagEntities: FetchedResults + // MARK: - Search State + + @State private var searchResults: [TagEntity] = [] + @State private var isSearchActive: Bool = false + // MARK: - Initialization init( @@ -30,7 +36,8 @@ struct CoreDataTagManagementView: View { searchFieldFocus: FocusState.Binding? = nil, fetchLimit: Int? = nil, sortOrder: TagSortOrder = .byCount, - availableTagsTitle: String? = nil, + availableLabelsTitle: String? = nil, + context: NSManagedObjectContext, onAddCustomTag: @escaping () -> Void, onToggleLabel: @escaping (String) -> Void, onRemoveLabel: @escaping (String) -> Void @@ -39,7 +46,8 @@ struct CoreDataTagManagementView: View { self.searchText = searchText self.searchFieldFocus = searchFieldFocus self.sortOrder = sortOrder - self.availableTagsTitle = availableTagsTitle + self.availableLabelsTitle = availableLabelsTitle + self.context = context self.onAddCustomTag = onAddCustomTag self.onToggleLabel = onToggleLabel self.onRemoveLabel = onRemoveLabel @@ -79,13 +87,16 @@ struct CoreDataTagManagementView: View { availableLabels selectedLabels } + .onChange(of: searchText.wrappedValue) { oldValue, newValue in + performSearch(query: newValue) + } } // MARK: - View Components @ViewBuilder private var searchField: some View { - TextField("Search or add new tag...", text: searchText) + TextField("Search or add new label...", text: searchText) .textFieldStyle(CustomTextFieldStyle()) .keyboardType(.default) .autocorrectionDisabled(true) @@ -102,7 +113,7 @@ struct CoreDataTagManagementView: View { !allTagNames.contains(where: { $0.lowercased() == searchText.wrappedValue.lowercased() }) && !selectedLabelsSet.contains(searchText.wrappedValue) { HStack { - Text("Add new tag:") + Text("Add new label:") .font(.subheadline) .foregroundColor(.secondary) Text(searchText.wrappedValue) @@ -132,7 +143,7 @@ struct CoreDataTagManagementView: View { if !tagEntities.isEmpty { VStack(alignment: .leading, spacing: 8) { HStack { - Text(searchText.wrappedValue.isEmpty ? (availableTagsTitle ?? "Available tags") : "Search results") + Text(searchText.wrappedValue.isEmpty ? (availableLabelsTitle ?? "Available labels") : "Search results") .font(.subheadline) .fontWeight(.medium) if !searchText.wrappedValue.isEmpty { @@ -144,16 +155,31 @@ struct CoreDataTagManagementView: View { } if availableUnselectedTagsCount == 0 { - VStack { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 24)) - .foregroundColor(.green) - Text("All tags selected") - .font(.caption) - .foregroundColor(.secondary) + // Show "All labels selected" only if there are actually filtered results + // Otherwise show "No labels found" for empty search results + if filteredTagsCount > 0 { + VStack { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.green) + Text("All labels selected") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } else if !searchText.wrappedValue.isEmpty { + VStack { + Image(systemName: "magnifyingglass") + .font(.system(size: 24)) + .foregroundColor(.secondary) + Text("No labels found") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) } - .frame(maxWidth: .infinity) - .padding(.vertical, 20) } else { labelsScrollView } @@ -174,17 +200,26 @@ struct CoreDataTagManagementView: View { alignment: .top, spacing: 8 ) { - ForEach(tagEntities, id: \.objectID) { entity in - if let name = entity.name, shouldShowTag(name) { - UnifiedLabelChip( - label: name, - isSelected: false, - isRemovable: false, - onTap: { - onToggleLabel(name) - } - ) - .fixedSize(horizontal: true, vertical: false) + // Use searchResults when search is active, otherwise use tagEntities + let tagsToDisplay = isSearchActive ? searchResults : Array(tagEntities) + + ForEach(tagsToDisplay, id: \.objectID) { entity in + if let name = entity.name { + // When searching, show all results (already filtered by predicate) + // When not searching, filter with shouldShowTag() + let shouldShow = isSearchActive ? !selectedLabelsSet.contains(name) : shouldShowTag(name) + + if shouldShow { + UnifiedLabelChip( + label: name, + isSelected: false, + isRemovable: false, + onTap: { + onToggleLabel(name) + } + ) + .fixedSize(horizontal: true, vertical: false) + } } } } @@ -200,7 +235,9 @@ struct CoreDataTagManagementView: View { } private var filteredTagsCount: Int { - if searchText.wrappedValue.isEmpty { + if isSearchActive { + return searchResults.count + } else if searchText.wrappedValue.isEmpty { return tagEntities.count } else { return tagEntities.filter { entity in @@ -211,12 +248,19 @@ struct CoreDataTagManagementView: View { } private var availableUnselectedTagsCount: Int { - tagEntities.filter { entity in - guard let name = entity.name else { return false } - let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue) - let isNotSelected = !selectedLabelsSet.contains(name) - return matchesSearch && isNotSelected - }.count + if isSearchActive { + return searchResults.filter { entity in + guard let name = entity.name else { return false } + return !selectedLabelsSet.contains(name) + }.count + } else { + return tagEntities.filter { entity in + guard let name = entity.name else { return false } + let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue) + let isNotSelected = !selectedLabelsSet.contains(name) + return matchesSearch && isNotSelected + }.count + } } private func shouldShowTag(_ name: String) -> Bool { @@ -225,11 +269,42 @@ struct CoreDataTagManagementView: View { return matchesSearch && isNotSelected } + private func performSearch(query: String) { + guard !query.isEmpty else { + isSearchActive = false + searchResults = [] + return + } + + // Search directly in Core Data without fetchLimit + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", query) + + // Use same sort order as main fetch + let sortDescriptors: [NSSortDescriptor] + switch sortOrder { + case .byCount: + sortDescriptors = [ + NSSortDescriptor(keyPath: \TagEntity.count, ascending: false), + NSSortDescriptor(keyPath: \TagEntity.name, ascending: true) + ] + case .alphabetically: + sortDescriptors = [ + NSSortDescriptor(keyPath: \TagEntity.name, ascending: true) + ] + } + fetchRequest.sortDescriptors = sortDescriptors + + // NO fetchLimit - search ALL tags in database + searchResults = (try? context.fetch(fetchRequest)) ?? [] + isSearchActive = true + } + @ViewBuilder private var selectedLabels: some View { if !selectedLabelsSet.isEmpty { VStack(alignment: .leading, spacing: 8) { - Text("Selected tags") + Text("Selected labels") .font(.subheadline) .fontWeight(.medium) diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index dd23504..7ed78fe 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -16,6 +16,7 @@ protocol UseCaseFactory { func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase func makeGetLabelsUseCase() -> PGetLabelsUseCase + func makeCreateLabelUseCase() -> PCreateLabelUseCase func makeSyncTagsUseCase() -> PSyncTagsUseCase func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase @@ -104,6 +105,12 @@ class DefaultUseCaseFactory: UseCaseFactory { return GetLabelsUseCase(labelsRepository: labelsRepository) } + func makeCreateLabelUseCase() -> PCreateLabelUseCase { + let api = API(tokenProvider: KeychainTokenProvider()) + let labelsRepository = LabelsRepository(api: api) + return CreateLabelUseCase(labelsRepository: labelsRepository) + } + func makeSyncTagsUseCase() -> PSyncTagsUseCase { let api = API(tokenProvider: KeychainTokenProvider()) let labelsRepository = LabelsRepository(api: api) diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index a86aba6..a6659fc 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -77,6 +77,10 @@ class MockUseCaseFactory: UseCaseFactory { MockGetLabelsUseCase() } + func makeCreateLabelUseCase() -> any PCreateLabelUseCase { + MockCreateLabelUseCase() + } + func makeSyncTagsUseCase() -> any PSyncTagsUseCase { MockSyncTagsUseCase() } @@ -129,6 +133,12 @@ class MockGetLabelsUseCase: PGetLabelsUseCase { } } +class MockCreateLabelUseCase: PCreateLabelUseCase { + func execute(name: String) async throws { + // Mock implementation - does nothing + } +} + class MockSyncTagsUseCase: PSyncTagsUseCase { func execute() async throws { // Mock implementation - does nothing From b98c71b8b367e204cbd2a1dc36c1741cd285305f Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Mon, 10 Nov 2025 21:32:32 +0100 Subject: [PATCH 39/39] Replace outdated CHANGELOG.md with RELEASE_NOTES.md references - Remove outdated CHANGELOG.md file - Update README.md links to point to the more current RELEASE_NOTES.md - RELEASE_NOTES.md contains detailed version history and feature descriptions --- CHANGELOG.md | 27 --------------------------- README.md | 4 ++-- 2 files changed, 2 insertions(+), 29 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a20e5fa..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,27 +0,0 @@ -# Changelog - -All changes to this project will be documented in this file. - -## Planned for Version 1.0.0 - -**Initial release:** -- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures) -- Share Extension for adding URLs from Safari and other apps -- Swipe actions for quick bookmark management -- Native iOS design with Dark Mode support -- Full iPad Support with Multi-Column Split View -- Font Customization -- Article View with Reading Time and Word Count -- Search functionality -- Support for tags -- Support for reading progress -- Save bookmarks when server is unavailable and sync when reconnected - -## Planned for Version 1.1.0 - -- [ ] Add support for bookmark filtering and sorting options -- [ ] Add support for collection management -- [ ] Add support for custom themes -- [ ] Text highlighting of selected text in a article -- [ ] Multiple selection of bookmarks for bulk actions - diff --git a/README.md b/README.md index ae254e1..ce6d876 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ For early access to new features and beta versions (use with caution). To partic What to test: - See the feature list below for an overview of what you can try out. -- For details and recent changes, please refer to the release notes in TestFlight or the [Changelog](./CHANGELOG.md). +- For details and recent changes, please refer to the release notes in TestFlight or the [Release Notes](./readeck/UI/Resources/RELEASE_NOTES.md). Please report any bugs, crashes, or suggestions directly through TestFlight, or email me at ilhallak@gmail.com. Thank you for helping make Readeck better! @@ -84,7 +84,7 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa ## Versions -[see Changelog](./CHANGELOG.md) +[see Release Notes](./readeck/UI/Resources/RELEASE_NOTES.md) ## Contributing