refactor: Implement state machine architecture for offline sync

- Replace multiple boolean properties with single OfflineBookmarkSyncState enum
- Add Use Case pattern for OfflineSyncManager with dependency injection
- Simplify LocalBookmarksSyncView using state-driven UI with external bindings
- Convert OfflineBookmarksViewModel to use @Observable instead of ObservableObject
- Move credentials from Core Data to Keychain for better persistence
- Implement comprehensive database migration for App Group containers
- Add structured logging throughout sync operations and API calls

Architecture improvements follow MVVM principles with clean separation of concerns.
This commit is contained in:
Ilyas Hallak 2025-08-18 22:58:42 +02:00
parent ef13faeff7
commit ffb41347af
24 changed files with 1071 additions and 419 deletions

View File

@ -114,15 +114,36 @@
}, },
"Cancel" : { "Cancel" : {
},
"Category-specific Levels" : {
},
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : {
}, },
"Clear cache" : { "Clear cache" : {
}, },
"Close" : { "Close" : {
},
"Configure log levels and categories" : {
},
"Critical" : {
}, },
"Data Management" : { "Data Management" : {
},
"Debug" : {
},
"DEBUG BUILD" : {
},
"Debug Settings" : {
}, },
"Delete" : { "Delete" : {
@ -171,30 +192,54 @@
}, },
"General" : { "General" : {
},
"Global Level" : {
},
"Global Minimum Level" : {
},
"Global Settings" : {
}, },
"https://example.com" : { "https://example.com" : {
}, },
"https://readeck.example.com" : { "https://readeck.example.com" : {
},
"Include Source Location" : {
},
"Info" : {
}, },
"Jump to last read position (%lld%%)" : { "Jump to last read position (%lld%%)" : {
}, },
"Key" : { "Key" : {
"extractionState" : "manual" "extractionState" : "manual"
},
"Level for %@" : {
}, },
"Loading %@" : { "Loading %@" : {
}, },
"Loading article..." : { "Loading article..." : {
},
"Logging Configuration" : {
}, },
"Login & Save" : { "Login & Save" : {
}, },
"Logout" : { "Logout" : {
},
"Logs below this level will be filtered out globally" : {
}, },
"Manage Labels" : { "Manage Labels" : {
@ -222,6 +267,9 @@
}, },
"No results" : { "No results" : {
},
"Notice" : {
}, },
"OK" : { "OK" : {
@ -277,9 +325,15 @@
}, },
"Remove" : { "Remove" : {
},
"Reset" : {
}, },
"Reset settings" : { "Reset settings" : {
},
"Reset to Defaults" : {
}, },
"Restore" : { "Restore" : {
@ -329,10 +383,13 @@
"Settings" : { "Settings" : {
}, },
"Speed" : { "Show Performance Logs" : {
}, },
"Successfully logged in" : { "Show Timestamps" : {
},
"Speed" : {
}, },
"Sync interval" : { "Sync interval" : {
@ -361,6 +418,9 @@
}, },
"Version %@" : { "Version %@" : {
},
"Warning" : {
}, },
"Your current server connection and login credentials." : { "Your current server connection and login credentials." : {

View File

@ -14,6 +14,8 @@ class ShareBookmarkViewModel: ObservableObject {
@Published var isServerReachable: Bool = true @Published var isServerReachable: Bool = true
let extensionContext: NSExtensionContext? let extensionContext: NSExtensionContext?
private let logger = Logger.viewModel
// Computed properties for pagination // Computed properties for pagination
var availableLabels: [BookmarkLabelDto] { var availableLabels: [BookmarkLabelDto] {
return labels.filter { !selectedLabels.contains($0.name) } return labels.filter { !selectedLabels.contains($0.name) }
@ -43,20 +45,29 @@ class ShareBookmarkViewModel: ObservableObject {
init(extensionContext: NSExtensionContext?) { init(extensionContext: NSExtensionContext?) {
self.extensionContext = extensionContext self.extensionContext = extensionContext
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
extractSharedContent() extractSharedContent()
} }
func onAppear() { func onAppear() {
logger.debug("ShareBookmarkViewModel appeared")
checkServerReachability() checkServerReachability()
loadLabels() loadLabels()
} }
private func checkServerReachability() { private func checkServerReachability() {
let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger)
isServerReachable = ServerConnectivity.isServerReachableSync() isServerReachable = ServerConnectivity.isServerReachableSync()
logger.info("Server reachability checked: \(isServerReachable)")
measurement.end()
} }
private func extractSharedContent() { private func extractSharedContent() {
guard let extensionContext = extensionContext else { return } logger.debug("Starting to extract shared content")
guard let extensionContext = extensionContext else {
logger.warning("No extension context available for content extraction")
return
}
for item in extensionContext.inputItems { for item in extensionContext.inputItems {
guard let inputItem = item as? NSExtensionItem else { continue } guard let inputItem = item as? NSExtensionItem else { continue }
for attachment in inputItem.attachments ?? [] { for attachment in inputItem.attachments ?? [] {
@ -65,6 +76,9 @@ class ShareBookmarkViewModel: ObservableObject {
DispatchQueue.main.async { DispatchQueue.main.async {
if let url = url as? URL { if let url = url as? URL {
self?.url = url.absoluteString self?.url = url.absoluteString
self?.logger.info("Extracted URL from shared content: \(url.absoluteString)")
} else if let error = error {
self?.logger.error("Failed to extract URL: \(error.localizedDescription)")
} }
} }
} }
@ -74,6 +88,9 @@ class ShareBookmarkViewModel: ObservableObject {
DispatchQueue.main.async { DispatchQueue.main.async {
if let text = text as? String, let url = URL(string: text) { if let text = text as? String, let url = URL(string: text) {
self?.url = url.absoluteString self?.url = url.absoluteString
self?.logger.info("Extracted URL from shared text: \(url.absoluteString)")
} else if let error = error {
self?.logger.error("Failed to extract text: \(error.localizedDescription)")
} }
} }
} }
@ -83,10 +100,12 @@ class ShareBookmarkViewModel: ObservableObject {
} }
func loadLabels() { func loadLabels() {
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
logger.debug("Starting to load labels")
Task { Task {
// Check if server is reachable // Check if server is reachable
let serverReachable = ServerConnectivity.isServerReachableSync() let serverReachable = ServerConnectivity.isServerReachableSync()
print("DEBUG: Server reachable: \(serverReachable)") logger.debug("Server reachable for labels: \(serverReachable)")
if serverReachable { if serverReachable {
// Load from API // Load from API
@ -96,7 +115,8 @@ class ShareBookmarkViewModel: ObservableObject {
let sorted = loaded.sorted { $0.count > $1.count } let sorted = loaded.sorted { $0.count > $1.count }
await MainActor.run { await MainActor.run {
self.labels = Array(sorted) self.labels = Array(sorted)
print("DEBUG: Loaded \(loaded.count) labels from API") self.logger.info("Loaded \(loaded.count) labels from API")
measurement.end()
} }
} else { } else {
// Load from local database // Load from local database
@ -107,51 +127,83 @@ class ShareBookmarkViewModel: ObservableObject {
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
await MainActor.run { await MainActor.run {
self.labels = localLabels self.labels = localLabels
self.logger.info("Loaded \(localLabels.count) labels from local database")
measurement.end()
} }
} }
} }
} }
func save() { func save() {
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
guard let url = url, !url.isEmpty else { guard let url = url, !url.isEmpty else {
logger.warning("Save attempted without valid URL")
statusMessage = ("No URL found.", true, "") statusMessage = ("No URL found.", true, "")
return return
} }
isSaving = true isSaving = true
logger.debug("Set saving state to true")
// Check server connectivity // Check server connectivity
if ServerConnectivity.isServerReachableSync() { let serverReachable = ServerConnectivity.isServerReachableSync()
logger.debug("Server connectivity for save: \(serverReachable)")
if serverReachable {
// Online - try to save via API // Online - try to save via API
logger.info("Attempting to save bookmark via API")
Task { Task {
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in 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 ? "" : "") self?.statusMessage = (message, error, error ? "" : "")
self?.isSaving = false self?.isSaving = false
if !error { if !error {
self?.logger.debug("Bookmark saved successfully, completing extension request")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) self?.completeExtensionRequest()
} }
} else {
self?.logger.error("Failed to save bookmark via API: \(message)")
} }
} }
} }
} else { } else {
// Server not reachable - save locally // Server not reachable - save locally
logger.info("Server not reachable, attempting local save")
let success = OfflineBookmarkManager.shared.saveOfflineBookmark( let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
url: url, url: url,
title: title, title: title,
tags: Array(selectedLabels) tags: Array(selectedLabels)
) )
logger.info("Local save result: \(success)")
DispatchQueue.main.async { DispatchQueue.main.async {
self.isSaving = false self.isSaving = false
if success { if success {
self.logger.info("Bookmark saved locally successfully")
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) self.completeExtensionRequest()
} }
} else { } else {
self.logger.error("Failed to save bookmark locally")
self.statusMessage = ("Failed to save locally.", true, "") self.statusMessage = ("Failed to save locally.", true, "")
} }
} }
} }
} }
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)")
} else {
self?.logger.info("Extension request completed successfully")
}
}
}
} }

View File

@ -1,8 +1,11 @@
import Foundation import Foundation
class SimpleAPI { class SimpleAPI {
private static let logger = Logger.network
// MARK: - API Methods // MARK: - API Methods
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async { static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
logger.info("Adding bookmark: \(url)")
guard let token = KeychainHelper.shared.loadToken() else { guard let token = KeychainHelper.shared.loadToken() else {
showStatus("No token found. Please log in via the main app.", true) showStatus("No token found. Please log in via the main app.", true)
return return
@ -28,25 +31,35 @@ class SimpleAPI {
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid server response for bookmark creation")
showStatus("Invalid server response.", true) showStatus("Invalid server response.", true)
return return
} }
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error" let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
logger.error("Server error \(httpResponse.statusCode): \(msg)")
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true) showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
return return
} }
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) { if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
logger.info("Bookmark created successfully: \(resp.message)")
showStatus("Saved: \(resp.message)", false) showStatus("Saved: \(resp.message)", false)
} else { } else {
logger.info("Bookmark created successfully")
showStatus("Bookmark saved!", false) showStatus("Bookmark saved!", false)
} }
} catch { } catch {
logger.logNetworkError(method: "POST", url: "/api/bookmarks", error: error)
showStatus("Network error: \(error.localizedDescription)", true) showStatus("Network error: \(error.localizedDescription)", true)
} }
} }
static func getBookmarkLabels(showStatus: @escaping (String, Bool) -> Void) async -> [BookmarkLabelDto]? { static func getBookmarkLabels(showStatus: @escaping (String, Bool) -> Void) async -> [BookmarkLabelDto]? {
logger.info("Fetching bookmark labels")
guard let token = KeychainHelper.shared.loadToken() else { guard let token = KeychainHelper.shared.loadToken() else {
showStatus("No token found. Please log in via the main app.", true) showStatus("No token found. Please log in via the main app.", true)
return nil return nil
@ -66,17 +79,25 @@ class SimpleAPI {
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid server response for labels request")
showStatus("Invalid server response.", true) showStatus("Invalid server response.", true)
return nil return nil
} }
logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode)
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error" let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
logger.error("Server error \(httpResponse.statusCode): \(msg)")
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true) showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
return nil return nil
} }
let labels = try JSONDecoder().decode([BookmarkLabelDto].self, from: data) let labels = try JSONDecoder().decode([BookmarkLabelDto].self, from: data)
logger.info("Successfully fetched \(labels.count) bookmark labels")
return labels return labels
} catch { } catch {
logger.logNetworkError(method: "GET", url: "/api/bookmarks/labels", error: error)
showStatus("Network error: \(error.localizedDescription)", true) showStatus("Network error: \(error.localizedDescription)", true)
return nil return nil
} }

View File

@ -23,8 +23,9 @@ protocol PAPI {
class API: PAPI { class API: PAPI {
let tokenProvider: TokenProvider let tokenProvider: TokenProvider
private var cachedBaseURL: String? private var cachedBaseURL: String?
private let logger = Logger.network
init(tokenProvider: TokenProvider = CoreDataTokenProvider()) { init(tokenProvider: TokenProvider = KeychainTokenProvider()) {
self.tokenProvider = tokenProvider self.tokenProvider = tokenProvider
} }
@ -73,7 +74,6 @@ class API: PAPI {
} }
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")")
throw APIError.serverError(httpResponse.statusCode) throw APIError.serverError(httpResponse.statusCode)
} }
@ -114,7 +114,6 @@ class API: PAPI {
} }
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")")
throw APIError.serverError(httpResponse.statusCode) throw APIError.serverError(httpResponse.statusCode)
} }
@ -159,7 +158,11 @@ class API: PAPI {
} }
func login(endpoint: String, username: String, password: String) async throws -> UserDto { func login(endpoint: String, username: String, password: String) async throws -> UserDto {
guard let url = URL(string: endpoint + "/api/auth") else { throw APIError.invalidURL } logger.info("Attempting login for user: \(username) at endpoint: \(endpoint)")
guard let url = URL(string: endpoint + "/api/auth") else {
logger.error("Invalid URL for login endpoint: \(endpoint)")
throw APIError.invalidURL
}
let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password) let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password)
let requestData = try JSONEncoder().encode(loginRequest) let requestData = try JSONEncoder().encode(loginRequest)
@ -168,20 +171,27 @@ class API: PAPI {
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = requestData request.httpBody = requestData
logger.logNetworkRequest(method: "POST", url: url.absoluteString)
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for login request")
throw APIError.invalidResponse throw APIError.invalidResponse
} }
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode) throw APIError.serverError(httpResponse.statusCode)
} }
logger.logNetworkRequest(method: "POST", url: url.absoluteString, statusCode: httpResponse.statusCode)
logger.info("Login successful for user: \(username)")
return try JSONDecoder().decode(UserDto.self, from: data) return try JSONDecoder().decode(UserDto.self, from: data)
} }
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPageDto { func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPageDto {
logger.debug("Fetching bookmarks with state: \(state?.rawValue ?? "all"), limit: \(limit ?? 0), offset: \(offset ?? 0)")
var endpoint = "/api/bookmarks" var endpoint = "/api/bookmarks"
var queryItems: [URLQueryItem] = [] var queryItems: [URLQueryItem] = []
@ -227,11 +237,16 @@ class API: PAPI {
endpoint += "?\(queryString)" endpoint += "?\(queryString)"
} }
logger.logNetworkRequest(method: "GET", url: await self.baseURL + (endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)"))
let (bookmarks, response) = try await makeJSONRequestWithHeaders( let (bookmarks, response) = try await makeJSONRequestWithHeaders(
endpoint: endpoint, endpoint: endpoint,
responseType: [BookmarkDto].self responseType: [BookmarkDto].self
) )
logger.logNetworkRequest(method: "GET", url: await self.baseURL + (endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)"), statusCode: response.statusCode)
logger.info("Fetched \(bookmarks.count) bookmarks")
// Header auslesen // Header auslesen
let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) } let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) }
let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) } let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) }
@ -249,38 +264,60 @@ class API: PAPI {
} }
func getBookmark(id: String) async throws -> BookmarkDetailDto { func getBookmark(id: String) async throws -> BookmarkDetailDto {
return try await makeJSONRequest( logger.debug("Fetching bookmark: \(id)")
endpoint: "/api/bookmarks/\(id)", let endpoint = "/api/bookmarks/\(id)"
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
let result = try await makeJSONRequest(
endpoint: endpoint,
responseType: BookmarkDetailDto.self responseType: BookmarkDetailDto.self
) )
logger.info("Successfully fetched bookmark: \(id)")
return result
} }
// Artikel als String laden statt als JSON // Artikel als String laden statt als JSON
func getBookmarkArticle(id: String) async throws -> String { func getBookmarkArticle(id: String) async throws -> String {
return try await makeStringRequest( logger.debug("Fetching article for bookmark: \(id)")
endpoint: "/api/bookmarks/\(id)/article" let endpoint = "/api/bookmarks/\(id)/article"
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
let result = try await makeStringRequest(
endpoint: endpoint
) )
logger.info("Successfully fetched article for bookmark: \(id)")
return result
} }
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto { func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto {
logger.info("Creating bookmark for URL: \(createRequest.url)")
let requestData = try JSONEncoder().encode(createRequest) let requestData = try JSONEncoder().encode(createRequest)
let endpoint = "/api/bookmarks"
logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint)
return try await makeJSONRequest( let result = try await makeJSONRequest(
endpoint: "/api/bookmarks", endpoint: endpoint,
method: .POST, method: .POST,
body: requestData, body: requestData,
responseType: CreateBookmarkResponseDto.self responseType: CreateBookmarkResponseDto.self
) )
logger.info("Successfully created bookmark: \(result.status)")
return result
} }
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws { func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws {
logger.info("Updating bookmark: \(id)")
let requestData = try JSONEncoder().encode(updateRequest) let requestData = try JSONEncoder().encode(updateRequest)
// PATCH Request ohne Response-Body erwarten // Use makeJSONRequest but ignore the response since PATCH returns no body
let baseURL = await self.baseURL let baseURL = await self.baseURL
let fullEndpoint = "/api/bookmarks/\(id)" let fullEndpoint = "/api/bookmarks/\(id)"
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
throw APIError.invalidURL throw APIError.invalidURL
} }
@ -295,24 +332,32 @@ class API: PAPI {
request.httpBody = requestData request.httpBody = requestData
logger.logNetworkRequest(method: "PATCH", url: url.absoluteString)
let (_, response) = try await URLSession.shared.data(for: request) let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for PATCH \(url.absoluteString)")
throw APIError.invalidResponse throw APIError.invalidResponse
} }
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
print("Server Error: \(httpResponse.statusCode)") logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode) throw APIError.serverError(httpResponse.statusCode)
} }
logger.logNetworkRequest(method: "PATCH", url: url.absoluteString, statusCode: httpResponse.statusCode)
logger.info("Successfully updated bookmark: \(id)")
} }
func deleteBookmark(id: String) async throws { func deleteBookmark(id: String) async throws {
// DELETE Request ohne Response-Body erwarten logger.info("Deleting bookmark: \(id)")
let baseURL = await self.baseURL let baseURL = await self.baseURL
let fullEndpoint = "/api/bookmarks/\(id)" let fullEndpoint = "/api/bookmarks/\(id)"
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
throw APIError.invalidURL throw APIError.invalidURL
} }
@ -324,25 +369,37 @@ class API: PAPI {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} }
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString)
let (_, response) = try await URLSession.shared.data(for: request) let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for DELETE \(url.absoluteString)")
throw APIError.invalidResponse throw APIError.invalidResponse
} }
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
print("Server Error: \(httpResponse.statusCode)") logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode) throw APIError.serverError(httpResponse.statusCode)
} }
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
logger.info("Successfully deleted bookmark: \(id)")
} }
func searchBookmarks(search: String) async throws -> BookmarksPageDto { func searchBookmarks(search: String) async throws -> BookmarksPageDto {
logger.debug("Searching bookmarks with query: \(search)")
let endpoint = "/api/bookmarks?search=\(search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" let endpoint = "/api/bookmarks?search=\(search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
let (bookmarks, response) = try await makeJSONRequestWithHeaders( let (bookmarks, response) = try await makeJSONRequestWithHeaders(
endpoint: endpoint, endpoint: endpoint,
responseType: [BookmarkDto].self responseType: [BookmarkDto].self
) )
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint, statusCode: response.statusCode)
logger.info("Found \(bookmarks.count) bookmarks matching search: \(search)")
let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) } let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) }
let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) } let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) }
let totalPages = response.value(forHTTPHeaderField: "Total-Pages").flatMap { Int($0) } let totalPages = response.value(forHTTPHeaderField: "Total-Pages").flatMap { Int($0) }
@ -359,10 +416,17 @@ class API: PAPI {
} }
func getBookmarkLabels() async throws -> [BookmarkLabelDto] { func getBookmarkLabels() async throws -> [BookmarkLabelDto] {
return try await makeJSONRequest( logger.debug("Fetching bookmark labels")
endpoint: "/api/bookmarks/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 responseType: [BookmarkLabelDto].self
) )
logger.info("Successfully fetched \(result.count) bookmark labels")
return result
} }
} }

View File

@ -4,22 +4,43 @@ import Foundation
class CoreDataManager { class CoreDataManager {
static let shared = CoreDataManager() static let shared = CoreDataManager()
private var isInMemoryStore = false
private let logger = Logger.data
private init() {} private init() {}
lazy var persistentContainer: NSPersistentContainer = { lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "readeck") // Try to find the model in the main bundle first, then in extension bundle
guard let modelURL = Bundle.main.url(forResource: "readeck", withExtension: "momd") ??
Bundle(for: CoreDataManager.self).url(forResource: "readeck", withExtension: "momd") else {
logger.error("Could not find Core Data model file")
fatalError("Core Data model 'readeck.xcdatamodeld' not found in bundle")
}
guard let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else {
logger.error("Could not load Core Data model from URL: \(modelURL)")
fatalError("Failed to load Core Data model")
}
let container = NSPersistentContainer(name: "readeck", managedObjectModel: managedObjectModel)
// Use App Group container for shared access with extensions // Use App Group container for shared access with extensions
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite") let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite")
if let storeURL = storeURL { if let storeURL = storeURL {
// Migrate existing database from app container to app group if needed
migrateStoreToAppGroupIfNeeded(targetURL: storeURL)
let storeDescription = NSPersistentStoreDescription(url: storeURL) let storeDescription = NSPersistentStoreDescription(url: storeURL)
container.persistentStoreDescriptions = [storeDescription] container.persistentStoreDescriptions = [storeDescription]
} }
container.loadPersistentStores { _, error in container.loadPersistentStores { [weak self] _, error in
if let error = error { if let error = error {
fatalError("Core Data error: \(error)") self?.logger.error("Core Data failed to load persistent store: \(error)", file: #file, function: #function, line: #line)
self?.setupInMemoryStore(container: container)
} else {
self?.logger.info("Core Data persistent store loaded successfully")
} }
} }
return container return container
@ -33,9 +54,126 @@ class CoreDataManager {
if context.hasChanges { if context.hasChanges {
do { do {
try context.save() try context.save()
logger.debug("Core Data context saved successfully")
} catch { } catch {
print("Failed to save Core Data context: \(error)") logger.error("Failed to save Core Data context: \(error.localizedDescription)")
} }
} }
} }
private func setupInMemoryStore(container: NSPersistentContainer) {
logger.warning("Setting up in-memory Core Data store as fallback")
isInMemoryStore = true
let inMemoryDescription = NSPersistentStoreDescription()
inMemoryDescription.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [inMemoryDescription]
container.loadPersistentStores { [weak self] _, error in
if let error = error {
self?.logger.error("Failed to setup in-memory store: \(error.localizedDescription)")
// Continue with empty container - app will work with reduced functionality
} else {
self?.logger.info("In-memory Core Data store setup successfully")
}
}
}
private func migrateStoreToAppGroupIfNeeded(targetURL: URL) {
let fileManager = FileManager.default
// Check if store already exists in app group
if fileManager.fileExists(atPath: targetURL.path) {
logger.info("Database already exists in app group container")
return
}
// Try multiple possible old locations for database
var searchPaths: [URL] = []
// 1. App's documents directory (most common old location)
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
searchPaths.append(documentsURL)
}
// 2. App's library directory
if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first {
searchPaths.append(libraryURL)
}
// 3. App's support directory
if let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
searchPaths.append(supportURL)
}
var foundOldStore = false
for searchPath in searchPaths {
let oldStoreURL = searchPath.appendingPathComponent("readeck.sqlite")
if fileManager.fileExists(atPath: oldStoreURL.path) {
logger.info("Found existing database at: \(oldStoreURL.path)")
foundOldStore = true
if migrateFromPath(oldStoreURL: oldStoreURL, targetURL: targetURL) {
break // Successfully migrated, stop searching
}
}
}
if !foundOldStore {
logger.info("No existing database found in any search location - starting fresh")
}
}
private func migrateFromPath(oldStoreURL: URL, targetURL: URL) -> Bool {
let fileManager = FileManager.default
let oldStoreWAL = oldStoreURL.appendingPathExtension("wal")
let oldStoreSHM = oldStoreURL.appendingPathExtension("shm")
logger.info("Migrating existing database from: \(oldStoreURL.path)")
logger.info("Migrating existing database to: \(targetURL.path)")
do {
// Create app group directory if it doesn't exist
let appGroupDirectory = targetURL.deletingLastPathComponent()
try fileManager.createDirectory(at: appGroupDirectory, withIntermediateDirectories: true)
// Copy main database file
try fileManager.copyItem(at: oldStoreURL, to: targetURL)
logger.info("Main database file migrated successfully")
// Copy WAL file if it exists
if fileManager.fileExists(atPath: oldStoreWAL.path) {
let targetWAL = targetURL.appendingPathExtension("wal")
try fileManager.copyItem(at: oldStoreWAL, to: targetWAL)
logger.info("WAL file migrated successfully")
}
// Copy SHM file if it exists
if fileManager.fileExists(atPath: oldStoreSHM.path) {
let targetSHM = targetURL.appendingPathExtension("shm")
try fileManager.copyItem(at: oldStoreSHM, to: targetSHM)
logger.info("SHM file migrated successfully")
}
// Remove old files after successful migration
try fileManager.removeItem(at: oldStoreURL)
if fileManager.fileExists(atPath: oldStoreWAL.path) {
try fileManager.removeItem(at: oldStoreWAL)
}
if fileManager.fileExists(atPath: oldStoreSHM.path) {
try fileManager.removeItem(at: oldStoreSHM)
}
logger.info("Database migration completed successfully")
return true
} catch {
logger.error("Failed to migrate database from \(oldStoreURL.path): \(error.localizedDescription)")
return false
}
}
} }

View File

@ -25,6 +25,33 @@ class KeychainHelper {
loadString(forKey: "readeck_endpoint") loadString(forKey: "readeck_endpoint")
} }
@discardableResult
func saveUsername(_ username: String) -> Bool {
saveString(username, forKey: "readeck_username")
}
func loadUsername() -> String? {
loadString(forKey: "readeck_username")
}
@discardableResult
func savePassword(_ password: String) -> Bool {
saveString(password, forKey: "readeck_password")
}
func loadPassword() -> String? {
loadString(forKey: "readeck_password")
}
@discardableResult
func clearCredentials() -> Bool {
let tokenCleared = saveString("", forKey: "readeck_token")
let endpointCleared = saveString("", forKey: "readeck_endpoint")
let usernameCleared = saveString("", forKey: "readeck_username")
let passwordCleared = saveString("", forKey: "readeck_password")
return tokenCleared && endpointCleared && usernameCleared && passwordCleared
}
// MARK: - Private generic helpers // MARK: - Private generic helpers
@discardableResult @discardableResult
private func saveString(_ value: String, forKey key: String) -> Bool { private func saveString(_ value: String, forKey key: String) -> Bool {

View File

@ -37,51 +37,49 @@ protocol PSettingsRepository {
class SettingsRepository: PSettingsRepository { class SettingsRepository: PSettingsRepository {
private let coreDataManager = CoreDataManager.shared private let coreDataManager = CoreDataManager.shared
private let userDefault = UserDefaults.standard private let userDefault = UserDefaults.standard
private let keychainHelper = KeychainHelper.shared
func saveSettings(_ settings: Settings) async throws { func saveSettings(_ settings: Settings) async throws {
// Save credentials to keychain
if let endpoint = settings.endpoint, !endpoint.isEmpty {
keychainHelper.saveEndpoint(endpoint)
}
if let username = settings.username, !username.isEmpty {
keychainHelper.saveUsername(username)
}
if let password = settings.password, !password.isEmpty {
keychainHelper.savePassword(password)
}
if let token = settings.token, !token.isEmpty {
keychainHelper.saveToken(token)
}
// Save UI preferences to Core Data
let context = coreDataManager.context let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
context.perform { context.perform {
do { do {
// Vorhandene Einstellungen löschen
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest() let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
if let existingSettings = try context.fetch(fetchRequest).first { let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
if let endpoint = settings.endpoint, !endpoint.isEmpty { if let fontFamily = settings.fontFamily {
existingSettings.endpoint = endpoint existingSettings.fontFamily = fontFamily.rawValue
}
if let username = settings.username, !username.isEmpty {
existingSettings.username = username
}
if let password = settings.password, !password.isEmpty {
existingSettings.password = password
}
if let token = settings.token, !token.isEmpty {
existingSettings.token = token
}
if let fontFamily = settings.fontFamily {
existingSettings.fontFamily = fontFamily.rawValue
}
if let fontSize = settings.fontSize {
existingSettings.fontSize = fontSize.rawValue
}
if let enableTTS = settings.enableTTS {
existingSettings.enableTTS = enableTTS
}
if let theme = settings.theme {
existingSettings.theme = theme.rawValue
}
try context.save()
} }
if let fontSize = settings.fontSize {
existingSettings.fontSize = fontSize.rawValue
}
if let enableTTS = settings.enableTTS {
existingSettings.enableTTS = enableTTS
}
if let theme = settings.theme {
existingSettings.theme = theme.rawValue
}
try context.save()
continuation.resume() continuation.resume()
} catch { } catch {
continuation.resume(throwing: error) continuation.resume(throwing: error)
@ -100,22 +98,26 @@ class SettingsRepository: PSettingsRepository {
fetchRequest.fetchLimit = 1 fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest) let settingEntities = try context.fetch(fetchRequest)
let settingEntity = settingEntities.first
if let settingEntity = settingEntities.first { // Load credentials from keychain only
let settings = Settings( let endpoint = self.keychainHelper.loadEndpoint()
endpoint: settingEntity.endpoint ?? "", let username = self.keychainHelper.loadUsername()
username: settingEntity.username ?? "", let password = self.keychainHelper.loadPassword()
password: settingEntity.password ?? "", let token = self.keychainHelper.loadToken()
token: settingEntity.token,
fontFamily: FontFamily(rawValue: settingEntity.fontFamily ?? FontFamily.system.rawValue), // Load UI preferences from Core Data
fontSize: FontSize(rawValue: settingEntity.fontSize ?? FontSize.medium.rawValue), let settings = Settings(
enableTTS: settingEntity.enableTTS, endpoint: endpoint,
theme: Theme(rawValue: settingEntity.theme ?? Theme.system.rawValue) username: username,
) password: password,
continuation.resume(returning: settings) token: token,
} else { fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
continuation.resume(returning: nil) fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
} enableTTS: settingEntity?.enableTTS,
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue)
)
continuation.resume(returning: settings)
} catch { } catch {
continuation.resume(throwing: error) continuation.resume(throwing: error)
} }
@ -124,6 +126,10 @@ class SettingsRepository: PSettingsRepository {
} }
func clearSettings() async throws { func clearSettings() async throws {
// Clear credentials from keychain
keychainHelper.clearCredentials()
// Also clear from Core Data
let context = coreDataManager.context let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
@ -146,128 +152,39 @@ class SettingsRepository: PSettingsRepository {
} }
func saveToken(_ token: String) async throws { func saveToken(_ token: String) async throws {
let context = coreDataManager.context // Save to keychain only
keychainHelper.saveToken(token)
return try await withCheckedThrowingContinuation { continuation in // Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren
context.perform { if !token.isEmpty {
do { self.hasFinishedSetup = true
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest() // Notification senden, dass sich der Setup-Status geändert hat
fetchRequest.fetchLimit = 1 DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
let settingEntities = try context.fetch(fetchRequest)
if let settingEntity = settingEntities.first {
settingEntity.token = token
} else {
let settingEntity = SettingEntity(context: context)
settingEntity.token = token
}
try context.save()
// Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren
if !token.isEmpty {
self.hasFinishedSetup = true
// Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
}
}
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
} }
} }
} }
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws { func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws {
let context = coreDataManager.context keychainHelper.saveEndpoint(endpoint)
return try await withCheckedThrowingContinuation { continuation in keychainHelper.saveUsername(username)
context.perform { keychainHelper.savePassword(password)
do { keychainHelper.saveToken(token)
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1 if !token.isEmpty {
let settingEntities = try context.fetch(fetchRequest) self.hasFinishedSetup = true
let settingEntity: SettingEntity DispatchQueue.main.async {
if let existing = settingEntities.first { NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
settingEntity = existing
} else {
settingEntity = SettingEntity(context: context)
}
settingEntity.endpoint = endpoint
settingEntity.username = username
settingEntity.password = password
settingEntity.token = token
try context.save()
// Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren
if !token.isEmpty {
self.hasFinishedSetup = true
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
}
}
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
} }
} }
} }
func saveUsername(_ username: String) async throws { func saveUsername(_ username: String) async throws {
let context = coreDataManager.context keychainHelper.saveUsername(username)
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
if let settingEntity = settingEntities.first {
settingEntity.username = username
} else {
let settingEntity = SettingEntity(context: context)
settingEntity.username = username
}
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
} }
func savePassword(_ password: String) async throws { func savePassword(_ password: String) async throws {
let context = coreDataManager.context keychainHelper.savePassword(password)
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
if let settingEntity = settingEntities.first {
settingEntity.password = password
} else {
let settingEntity = SettingEntity(context: context)
settingEntity.password = password
}
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
} }
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws { func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws {

View File

@ -7,66 +7,22 @@ protocol TokenProvider {
func clearToken() async func clearToken() async
} }
class CoreDataTokenProvider: TokenProvider { class KeychainTokenProvider: TokenProvider {
private let settingsRepository = SettingsRepository()
private var cachedSettings: Settings?
private var isLoaded = false
private let keychainHelper = KeychainHelper.shared private let keychainHelper = KeychainHelper.shared
private func loadSettingsIfNeeded() async {
guard isLoaded == false || cachedSettings == nil else { return }
do {
cachedSettings = try await settingsRepository.loadSettings()
isLoaded = true
} catch {
print("Failed to load settings: \(error)")
cachedSettings = nil
}
}
func getToken() async -> String? { func getToken() async -> String? {
await loadSettingsIfNeeded() return keychainHelper.loadToken()
return cachedSettings?.token
} }
func getEndpoint() async -> String? { func getEndpoint() async -> String? {
await loadSettingsIfNeeded() return keychainHelper.loadEndpoint()
// Basis-URL ohne /api Suffix, da es in der API-Klasse hinzugefügt wird
return cachedSettings?.endpoint
} }
func setToken(_ token: String) async { func setToken(_ token: String) async {
await loadSettingsIfNeeded()
do {
try await settingsRepository.saveToken(token)
saveTokenToKeychain(token: token)
if cachedSettings != nil {
cachedSettings!.token = token
}
} catch {
print("Failed to save token: \(error)")
}
}
func clearToken() async {
do {
try await settingsRepository.clearSettings()
cachedSettings = nil
saveTokenToKeychain(token: "")
} catch {
print("Failed to clear settings: \(error)")
}
}
// MARK: - Keychain Support
func saveTokenToKeychain(token: String) {
keychainHelper.saveToken(token) keychainHelper.saveToken(token)
} }
func loadTokenFromKeychain() -> String? { func clearToken() async {
keychainHelper.loadToken() keychainHelper.clearCredentials()
} }
} }

View File

@ -0,0 +1,62 @@
import Foundation
enum OfflineBookmarkSyncState: Equatable {
case idle
case pending(count: Int)
case syncing(count: Int, status: String?)
case success(syncedCount: Int)
case error(String)
var localBookmarkCount: Int {
switch self {
case .idle:
return 0
case .pending(let count):
return count
case .syncing(let count, _):
return count
case .success:
return 0
case .error:
return 0
}
}
var isSyncing: Bool {
switch self {
case .syncing:
return true
default:
return false
}
}
var syncStatus: String? {
switch self {
case .syncing(_, let status):
return status
case .error(let message):
return message
default:
return nil
}
}
var showSuccessMessage: Bool {
switch self {
case .success:
return true
default:
return false
}
}
var syncedBookmarkCount: Int {
switch self {
case .success(let count):
return count
default:
return 0
}
}
}

View File

@ -0,0 +1,30 @@
import Foundation
import Combine
protocol POfflineBookmarkSyncUseCase {
var isSyncing: AnyPublisher<Bool, Never> { get }
var syncStatus: AnyPublisher<String?, Never> { get }
func getOfflineBookmarksCount() -> Int
func syncOfflineBookmarks() async
}
class OfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
private let syncManager = OfflineSyncManager.shared
var isSyncing: AnyPublisher<Bool, Never> {
syncManager.$isSyncing.eraseToAnyPublisher()
}
var syncStatus: AnyPublisher<String?, Never> {
syncManager.$syncStatus.eraseToAnyPublisher()
}
func getOfflineBookmarksCount() -> Int {
return syncManager.getOfflineBookmarksCount()
}
func syncOfflineBookmarks() async {
await syncManager.syncOfflineBookmarks()
}
}

View File

@ -13,8 +13,5 @@ class SaveServerSettingsUseCase: PSaveServerSettingsUseCase {
func execute(endpoint: String, username: String, password: String, token: String) async throws { func execute(endpoint: String, username: String, password: String, token: String) async throws {
try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token) try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token)
KeychainHelper.shared.saveToken(token)
KeychainHelper.shared.saveEndpoint(endpoint)
print("token saved", KeychainHelper.shared.loadToken())
} }
} }

View File

@ -1,9 +1,6 @@
import Foundation import Foundation
protocol PSaveSettingsUseCase { protocol PSaveSettingsUseCase {
func execute(endpoint: String, username: String, password: String) async throws
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws
func execute(token: String) async throws
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws
func execute(enableTTS: Bool) async throws func execute(enableTTS: Bool) async throws
func execute(theme: Theme) async throws func execute(theme: Theme) async throws
@ -16,35 +13,6 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
self.settingsRepository = settingsRepository self.settingsRepository = settingsRepository
} }
func execute(endpoint: String, username: String, password: String) async throws {
try await settingsRepository.saveSettings(
.init(
endpoint: endpoint,
username: username,
password: password
)
)
}
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws {
try await settingsRepository.saveSettings(
.init(
endpoint: endpoint,
username: username,
password: password,
hasFinishedSetup: hasFinishedSetup
)
)
}
func execute(token: String) async throws {
try await settingsRepository.saveSettings(
.init(
token: token
)
)
}
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws { func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {
try await settingsRepository.saveSettings( try await settingsRepository.saveSettings(
.init( .init(

View File

@ -248,3 +248,15 @@ extension Dictionary {
} }
} }
// MARK: - Debug Build Detection
extension Bundle {
var isDebugBuild: Bool {
#if DEBUG
return true
#else
return false
#endif
}
}

View File

@ -1,105 +0,0 @@
import SwiftUI
struct LocalBookmarksSyncView: View {
@StateObject private var syncManager = OfflineSyncManager.shared
@StateObject private var serverConnectivity = ServerConnectivity.shared
@State private var showSuccessMessage = false
@State private var syncedBookmarkCount = 0
let localBookmarkCount: Int
init(bookmarkCount: Int) {
self.localBookmarkCount = bookmarkCount
}
var body: some View {
Group {
if showSuccessMessage {
VStack(spacing: 4) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.imageScale(.small)
Text("\(syncedBookmarkCount) bookmark\(syncedBookmarkCount == 1 ? "" : "s") synced successfully")
.font(.caption2)
.foregroundColor(.green)
Spacer()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.secondarySystemBackground))
.cornerRadius(8)
.padding(.horizontal)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
withAnimation {
showSuccessMessage = false
}
}
}
} else if localBookmarkCount > 0 || syncManager.isSyncing {
VStack(spacing: 4) {
HStack {
Image(systemName: syncManager.isSyncing ? "arrow.triangle.2.circlepath" : "externaldrive.badge.wifi")
.foregroundColor(syncManager.isSyncing ? .blue : .blue)
.imageScale(.medium)
if syncManager.isSyncing {
Text("Syncing with server...")
.font(.subheadline)
.foregroundColor(.blue)
} else {
Text("\(localBookmarkCount) bookmark\(localBookmarkCount == 1 ? "" : "s") waiting for sync")
.font(.subheadline)
.foregroundColor(.primary)
}
Spacer()
if !syncManager.isSyncing && localBookmarkCount > 0 {
Button {
syncedBookmarkCount = localBookmarkCount // Store count before sync
Task {
await syncManager.syncOfflineBookmarks()
}
} label: {
Image(systemName: "icloud.and.arrow.up")
.foregroundColor(.blue)
}
}
}
if let status = syncManager.syncStatus {
Text(status)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.secondarySystemBackground))
.cornerRadius(8)
.padding(.horizontal)
.animation(.easeInOut, value: syncManager.isSyncing)
.animation(.easeInOut, value: syncManager.syncStatus)
}
}
.onChange(of: syncManager.isSyncing) { _ in
if !syncManager.isSyncing {
// Show success message if all bookmarks are synced
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let currentCount = syncManager.getOfflineBookmarksCount()
if currentCount == 0 {
withAnimation {
showSuccessMessage = true
}
}
}
}
}
}
}

View File

@ -17,12 +17,13 @@ protocol UseCaseFactory {
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
func makeGetLabelsUseCase() -> PGetLabelsUseCase func makeGetLabelsUseCase() -> PGetLabelsUseCase
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
} }
class DefaultUseCaseFactory: UseCaseFactory { class DefaultUseCaseFactory: UseCaseFactory {
private let tokenProvider = CoreDataTokenProvider() private let tokenProvider = KeychainTokenProvider()
private lazy var api: PAPI = API(tokenProvider: tokenProvider) private lazy var api: PAPI = API(tokenProvider: tokenProvider)
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository) private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api) private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
@ -89,7 +90,7 @@ class DefaultUseCaseFactory: UseCaseFactory {
} }
func makeGetLabelsUseCase() -> PGetLabelsUseCase { func makeGetLabelsUseCase() -> PGetLabelsUseCase {
let api = API(tokenProvider: CoreDataTokenProvider()) let api = API(tokenProvider: KeychainTokenProvider())
let labelsRepository = LabelsRepository(api: api) let labelsRepository = LabelsRepository(api: api)
return GetLabelsUseCase(labelsRepository: labelsRepository) return GetLabelsUseCase(labelsRepository: labelsRepository)
} }
@ -97,4 +98,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase { func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase() return AddTextToSpeechQueueUseCase()
} }
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
return OfflineBookmarkSyncUseCase()
}
} }

View File

@ -6,8 +6,13 @@
// //
import Foundation import Foundation
import Combine
class MockUseCaseFactory: UseCaseFactory { class MockUseCaseFactory: UseCaseFactory {
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
MockOfflineBookmarkSyncUseCase()
}
func makeLoginUseCase() -> any PLoginUseCase { func makeLoginUseCase() -> any PLoginUseCase {
MockLoginUserCase() MockLoginUserCase()
} }
@ -181,6 +186,24 @@ class MockAddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase {
func execute(bookmarkDetail: BookmarkDetail) {} func execute(bookmarkDetail: BookmarkDetail) {}
} }
class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
var isSyncing: AnyPublisher<Bool, Never> {
Just(false).eraseToAnyPublisher()
}
var syncStatus: AnyPublisher<String?, Never> {
Just(nil).eraseToAnyPublisher()
}
func getOfflineBookmarksCount() -> Int {
return 0
}
func syncOfflineBookmarks() async {
// Mock implementation - do nothing
}
}
extension Bookmark { extension Bookmark {
static let mock: Bookmark = .init( 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) 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)

View File

@ -0,0 +1,130 @@
import SwiftUI
struct LocalBookmarksSyncView: View {
let state: OfflineBookmarkSyncState
let onSyncTapped: () async -> Void
init(state: OfflineBookmarkSyncState, onSyncTapped: @escaping () async -> Void) {
self.state = state
self.onSyncTapped = onSyncTapped
}
var body: some View {
Group {
switch state {
case .idle:
EmptyView()
case .pending(let count):
pendingView(count: count)
case .syncing(let count, let status):
syncingView(count: count, status: status)
case .success(let syncedCount):
successView(syncedCount: syncedCount)
case .error(let message):
errorView(message: message)
}
}
}
@ViewBuilder
private func pendingView(count: Int) -> some View {
syncContainerView {
HStack {
Image(systemName: "externaldrive.badge.wifi")
.foregroundColor(.blue)
.imageScale(.medium)
Text("\(count) bookmark\(count == 1 ? "" : "s") waiting for sync")
.font(.subheadline)
.foregroundColor(.primary)
Spacer()
Button {
Task { await onSyncTapped() }
} label: {
Image(systemName: "icloud.and.arrow.up")
.foregroundColor(.blue)
}
}
}
}
@ViewBuilder
private func syncingView(count: Int, status: String?) -> some View {
syncContainerView {
VStack(spacing: 4) {
HStack {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundColor(.blue)
.imageScale(.medium)
Text("Syncing with server...")
.font(.subheadline)
.foregroundColor(.blue)
Spacer()
}
if let status = status {
HStack {
Text(status)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
Spacer()
}
}
}
}
}
@ViewBuilder
private func successView(syncedCount: Int) -> some View {
syncContainerView {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.imageScale(.small)
Text("\(syncedCount) bookmark\(syncedCount == 1 ? "" : "s") synced successfully")
.font(.caption2)
.foregroundColor(.green)
Spacer()
}
}
}
@ViewBuilder
private func errorView(message: String) -> some View {
syncContainerView {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.imageScale(.small)
Text(message)
.font(.caption2)
.foregroundColor(.orange)
Spacer()
}
}
}
@ViewBuilder
private func syncContainerView<Content: View>(@ViewBuilder content: () -> Content) -> some View {
content()
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.secondarySystemBackground))
.cornerRadius(8)
.padding(.horizontal)
.animation(.easeInOut, value: state)
}
}

View File

@ -0,0 +1,131 @@
import Foundation
import SwiftUI
import Combine
@Observable
class OfflineBookmarksViewModel {
var state: OfflineBookmarkSyncState = .idle
private let syncUseCase: POfflineBookmarkSyncUseCase
private var cancellables = Set<AnyCancellable>()
private var successTimer: Timer?
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
self.syncUseCase = syncUseCase
setupBindings()
updateState()
}
private func setupBindings() {
// Observe sync state changes
syncUseCase.isSyncing
.receive(on: DispatchQueue.main)
.sink { [weak self] isSyncing in
self?.handleSyncStateChange(isSyncing: isSyncing)
}
.store(in: &cancellables)
// Observe sync status changes
syncUseCase.syncStatus
.receive(on: DispatchQueue.main)
.sink { [weak self] status in
self?.handleSyncStatusChange(status: status)
}
.store(in: &cancellables)
// Update count on app lifecycle events
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateState()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateState()
}
.store(in: &cancellables)
}
func updateState() {
let count = syncUseCase.getOfflineBookmarksCount()
switch state {
case .idle:
if count > 0 {
state = .pending(count: count)
}
case .pending:
if count > 0 {
state = .pending(count: count)
} else {
state = .idle
}
case .syncing:
// Keep syncing state, will be updated by handleSyncStateChange
break
case .success:
// Success state is temporary, handled by timer
break
case .error:
// Update count even in error state
if count > 0 {
state = .pending(count: count)
} else {
state = .idle
}
}
}
func syncOfflineBookmarks() async {
guard case .pending(let count) = state else { return }
state = .syncing(count: count, status: nil)
await syncUseCase.syncOfflineBookmarks()
}
private func handleSyncStateChange(isSyncing: Bool) {
if isSyncing {
// If we're not already in syncing state, transition to it
if case .pending(let count) = state {
state = .syncing(count: count, status: nil)
}
} else {
// Sync completed
Task { @MainActor in
// Small delay to ensure count is updated
try await Task.sleep(nanoseconds: 500_000_000)
let currentCount = syncUseCase.getOfflineBookmarksCount()
if case .syncing(let originalCount, _) = state {
if currentCount == 0 {
// Success - all bookmarks synced
state = .success(syncedCount: originalCount)
// Auto-hide success message after 2 seconds
successTimer?.invalidate()
successTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
self?.state = .idle
}
} else {
// Some bookmarks remain
state = .pending(count: currentCount)
}
}
}
}
}
private func handleSyncStatusChange(status: String?) {
if case .syncing(let count, _) = state {
state = .syncing(count: count, status: status)
}
}
deinit {
successTimer?.invalidate()
}
}

View File

@ -13,6 +13,7 @@ struct PadSidebarView: View {
@State private var selectedTag: BookmarkLabel? @State private var selectedTag: BookmarkLabel?
@EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings @EnvironmentObject var appSettings: AppSettings
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags] private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
@ -37,6 +38,20 @@ struct PadSidebarView: View {
.listRowBackground(Color(R.color.menu_sidebar_bg)) .listRowBackground(Color(R.color.menu_sidebar_bg))
} }
} }
if case .idle = offlineBookmarksViewModel.state {
// Don't show anything for idle state
} else {
Section {
VStack {
LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) {
await offlineBookmarksViewModel.syncOfflineBookmarks()
}
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
}
} }
.listRowBackground(Color(R.color.menu_sidebar_bg)) .listRowBackground(Color(R.color.menu_sidebar_bg))
.background(Color(R.color.menu_sidebar_bg)) .background(Color(R.color.menu_sidebar_bg))

View File

@ -13,8 +13,7 @@ struct PhoneTabView: View {
@State private var selectedMoreTab: SidebarTab? = nil @State private var selectedMoreTab: SidebarTab? = nil
@State private var selectedTabIndex: Int = 1 @State private var selectedTabIndex: Int = 1
@StateObject private var syncManager = OfflineSyncManager.shared @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
@State private var phoneTabLocalBookmarkCount = 0
@EnvironmentObject var appSettings: AppSettings @EnvironmentObject var appSettings: AppSettings
@ -25,31 +24,9 @@ struct PhoneTabView: View {
moreTabContent moreTabContent
} }
.accentColor(.accentColor) .accentColor(.accentColor)
.onAppear {
updateLocalBookmarkCount()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
updateLocalBookmarkCount()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
updateLocalBookmarkCount()
}
.onChange(of: syncManager.isSyncing) {
if !syncManager.isSyncing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
updateLocalBookmarkCount()
}
}
}
} }
} }
private func updateLocalBookmarkCount() {
let count = syncManager.getOfflineBookmarksCount()
DispatchQueue.main.async {
self.phoneTabLocalBookmarkCount = count
}
}
// MARK: - Tab Content // MARK: - Tab Content
@ -78,7 +55,7 @@ struct PhoneTabView: View {
.tabItem { .tabItem {
Label("More", systemImage: "ellipsis") Label("More", systemImage: "ellipsis")
} }
.badge(phoneTabLocalBookmarkCount > 0 ? phoneTabLocalBookmarkCount : 0) .badge(offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0)
.tag(mainTabs.count) .tag(mainTabs.count)
.onAppear { .onAppear {
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil { if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
@ -106,10 +83,14 @@ struct PhoneTabView: View {
.listRowBackground(Color(R.color.bookmark_list_bg)) .listRowBackground(Color(R.color.bookmark_list_bg))
} }
if phoneTabLocalBookmarkCount > 0 { if case .idle = offlineBookmarksViewModel.state {
// Don't show anything for idle state
} else {
Section { Section {
VStack { VStack {
LocalBookmarksSyncView(bookmarkCount: phoneTabLocalBookmarkCount) LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) {
await offlineBookmarksViewModel.syncOfflineBookmarks()
}
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())

View File

@ -0,0 +1,131 @@
//
// LoggingConfigurationView.swift
// readeck
//
// 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)
}
}
.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()
}
}
.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"
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 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()
}

View File

@ -26,6 +26,11 @@ struct SettingsContainerView: View {
SettingsServerView() SettingsServerView()
.cardStyle() .cardStyle()
// Debug-only Logging Configuration
if Bundle.main.isDebugBuild {
debugSettingsSection
}
} }
.padding() .padding()
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
@ -39,6 +44,52 @@ struct SettingsContainerView: View {
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} }
@ViewBuilder
private var debugSettingsSection: some View {
VStack(alignment: .leading, spacing: 16) {
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)
.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)
.foregroundColor(.secondary)
}
}
}
.cardStyle()
}
@ViewBuilder @ViewBuilder
func AppInfo() -> some View { func AppInfo() -> some View {
VStack(spacing: 4) { VStack(spacing: 4) {

View File

@ -92,17 +92,6 @@ struct SettingsServerView: View {
} }
} }
// Connection Status
if viewModel.isLoggedIn {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Successfully logged in")
.foregroundColor(.green)
.font(.caption)
}
}
// Messages // Messages
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
HStack { HStack {

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24G84" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="ArticleURLEntity" representedClassName="ArticleURLEntity" syncable="YES" codeGenerationType="class"> <entity name="ArticleURLEntity" representedClassName="ArticleURLEntity" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="tags" optional="YES" attributeType="String"/> <attribute name="tags" optional="YES" attributeType="String"/>
@ -52,13 +52,10 @@
</entity> </entity>
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class"> <entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="fontFamily" optional="YES" attributeType="String"/> <attribute name="fontFamily" optional="YES" attributeType="String"/>
<attribute name="fontSize" optional="YES" attributeType="String"/> <attribute name="fontSize" optional="YES" attributeType="String"/>
<attribute name="password" optional="YES" attributeType="String"/>
<attribute name="theme" optional="YES" attributeType="String"/> <attribute name="theme" optional="YES" attributeType="String"/>
<attribute name="token" optional="YES" attributeType="String"/> <attribute name="token" optional="YES" attributeType="String"/>
<attribute name="username" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class"> <entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/> <attribute name="name" optional="YES" attributeType="String"/>