Compare commits

...

5 Commits

Author SHA1 Message Date
660f271982 bumped build version 2025-08-20 21:09:04 +02:00
fd50f28628 feat: Enable text selection and improve bookmark error handling
- Enable text selection and copy functionality in article WebView
- Add CSS properties for proper text selection on iOS devices
- Preserve bookmark data during reload errors for better UX
- Update documentation with new offline sync features
- Restructure changelog with planned version roadmap

Users can now select and copy text from articles, and bookmark lists
remain visible even when refresh operations fail.
2025-08-20 21:00:50 +02:00
76bc28ae02 feat: Improve UI components and performance optimizations
- Refactor BookmarksView with better error handling and loading states
- Optimize BookmarkLabelsViewModel with cached properties and reduced recomputation
- Fix Core Data thread safety in LabelsRepository with performAndWait
- Enhance TagManagementView with sorted selected labels display
- Clean up ShareBookmarkViewModel comments
- Update localization strings for error states
- Bump build version to 19

These changes improve overall app performance and user experience across
bookmark management workflows.
2025-08-20 20:38:42 +02:00
692f34d2ce fix: Prevent redundant timer creation in offline sync state machine
- Guard against multiple concurrent completion timers in sync state handling
- Only trigger completion timer when transitioning from actual syncing state
- Remove debug logging that impacted performance during scroll operations

This resolves scroll performance issues introduced by excessive timer creation
in the offline bookmark synchronization workflow.
2025-08-20 20:35:53 +02:00
ffb41347af 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.
2025-08-18 22:58:42 +02:00
33 changed files with 1360 additions and 604 deletions

View File

@ -2,7 +2,7 @@
All changes to this project will be documented in this file.
## 1.0.0
## Planned for Version 1.0.0
**Initial release:**
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
@ -15,11 +15,13 @@ All changes to this project will be documented in this file.
- 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
## [Unreleased]
### Planned Features
- [ ] Add support for bookmark filtering and sorting options
- [ ] Offline sync with Core Data
- [ ] Add support for collection management
- [ ] Add offline sync capabilities
- [ ] 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

View File

@ -48,9 +48,6 @@
},
"%lld min" : {
},
"%lld minutes" : {
},
"%lld." : {
@ -102,12 +99,6 @@
},
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
},
"Automatic sync" : {
},
"Automatically mark articles as read" : {
},
"Available tags" : {
@ -115,13 +106,28 @@
"Cancel" : {
},
"Clear cache" : {
"Category-specific Levels" : {
},
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : {
},
"Close" : {
},
"Data Management" : {
"Configure log levels and categories" : {
},
"Critical" : {
},
"Debug" : {
},
"DEBUG BUILD" : {
},
"Debug Settings" : {
},
"Delete" : {
@ -171,30 +177,54 @@
},
"General" : {
},
"Global Level" : {
},
"Global Minimum Level" : {
},
"Global Settings" : {
},
"https://example.com" : {
},
"https://readeck.example.com" : {
},
"Include Source Location" : {
},
"Info" : {
},
"Jump to last read position (%lld%%)" : {
},
"Key" : {
"extractionState" : "manual"
},
"Level for %@" : {
},
"Loading %@" : {
},
"Loading article..." : {
},
"Logging Configuration" : {
},
"Login & Save" : {
},
"Logout" : {
},
"Logs below this level will be filtered out globally" : {
},
"Manage Labels" : {
@ -223,10 +253,10 @@
"No results" : {
},
"OK" : {
"Notice" : {
},
"Open external links in in-app Safari" : {
"OK" : {
},
"Optional: Custom title" : {
@ -271,14 +301,14 @@
}
}
}
},
"Reading Settings" : {
},
"Remove" : {
},
"Reset settings" : {
"Reset" : {
},
"Reset to Defaults" : {
},
"Restore" : {
@ -286,9 +316,6 @@
},
"Resume listening" : {
},
"Safari Reader Mode" : {
},
"Save bookmark" : {
@ -328,18 +355,15 @@
},
"Settings" : {
},
"Show Performance Logs" : {
},
"Show Timestamps" : {
},
"Speed" : {
},
"Successfully logged in" : {
},
"Sync interval" : {
},
"Sync Settings" : {
},
"Syncing with server..." : {
@ -349,6 +373,12 @@
},
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." : {
},
"Try Again" : {
},
"Unable to load bookmarks" : {
},
"Unarchive Bookmark" : {
@ -361,6 +391,9 @@
},
"Version %@" : {
},
"Warning" : {
},
"Your current server connection and login credentials." : {

View File

@ -45,6 +45,7 @@ If you are interested in joining the internal beta, please contact me directly a
- Article View with Reading Time and Word Count
- Search functionality
- Support for reading progress
- Save bookmarks when server is unavailable and sync when reconnected
## Configuration

View File

@ -14,6 +14,8 @@ class ShareBookmarkViewModel: ObservableObject {
@Published var isServerReachable: Bool = true
let extensionContext: NSExtensionContext?
private let logger = Logger.viewModel
// Computed properties for pagination
var availableLabels: [BookmarkLabelDto] {
return labels.filter { !selectedLabels.contains($0.name) }
@ -43,20 +45,29 @@ class ShareBookmarkViewModel: ObservableObject {
init(extensionContext: NSExtensionContext?) {
self.extensionContext = extensionContext
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
extractSharedContent()
}
func onAppear() {
logger.debug("ShareBookmarkViewModel appeared")
checkServerReachability()
loadLabels()
}
private func checkServerReachability() {
let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger)
isServerReachable = ServerConnectivity.isServerReachableSync()
logger.info("Server reachability checked: \(isServerReachable)")
measurement.end()
}
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 {
guard let inputItem = item as? NSExtensionItem else { continue }
for attachment in inputItem.attachments ?? [] {
@ -65,6 +76,9 @@ class ShareBookmarkViewModel: ObservableObject {
DispatchQueue.main.async {
if let url = url as? URL {
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 {
if let text = text as? String, let url = URL(string: text) {
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,23 +100,23 @@ class ShareBookmarkViewModel: ObservableObject {
}
func loadLabels() {
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
logger.debug("Starting to load labels")
Task {
// Check if server is reachable
let serverReachable = ServerConnectivity.isServerReachableSync()
print("DEBUG: Server reachable: \(serverReachable)")
logger.debug("Server reachable for labels: \(serverReachable)")
if serverReachable {
// Load from API
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
self?.statusMessage = (message, error, error ? "" : "")
} ?? []
let sorted = loaded.sorted { $0.count > $1.count }
await MainActor.run {
self.labels = Array(sorted)
print("DEBUG: Loaded \(loaded.count) labels from API")
self.logger.info("Loaded \(loaded.count) labels from API")
measurement.end()
}
} else {
// Load from local database
let localTags = OfflineBookmarkManager.shared.getTags()
let localLabels = localTags.enumerated().map { index, tagName in
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
@ -107,51 +124,83 @@ class ShareBookmarkViewModel: ObservableObject {
.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()
}
}
}
}
func save() {
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
guard let url = url, !url.isEmpty else {
logger.warning("Save attempted without valid URL")
statusMessage = ("No URL found.", true, "")
return
}
isSaving = true
logger.debug("Set saving state to true")
// 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
logger.info("Attempting to save bookmark via API")
Task {
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?.isSaving = false
if !error {
self?.logger.debug("Bookmark saved successfully, completing extension request")
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 {
// 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.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
self.completeExtensionRequest()
}
} else {
self.logger.error("Failed to save bookmark locally")
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
class SimpleAPI {
private static let logger = Logger.network
// MARK: - API Methods
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 {
showStatus("No token found. Please log in via the main app.", true)
return
@ -28,25 +31,35 @@ class SimpleAPI {
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid server response for bookmark creation")
showStatus("Invalid server response.", true)
return
}
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
logger.error("Server error \(httpResponse.statusCode): \(msg)")
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
return
}
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
logger.info("Bookmark created successfully: \(resp.message)")
showStatus("Saved: \(resp.message)", false)
} else {
logger.info("Bookmark created successfully")
showStatus("Bookmark saved!", false)
}
} catch {
logger.logNetworkError(method: "POST", url: "/api/bookmarks", error: error)
showStatus("Network error: \(error.localizedDescription)", true)
}
}
static func getBookmarkLabels(showStatus: @escaping (String, Bool) -> Void) async -> [BookmarkLabelDto]? {
logger.info("Fetching bookmark labels")
guard let token = KeychainHelper.shared.loadToken() else {
showStatus("No token found. Please log in via the main app.", true)
return nil
@ -66,17 +79,25 @@ class SimpleAPI {
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid server response for labels request")
showStatus("Invalid server response.", true)
return nil
}
logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode)
guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
logger.error("Server error \(httpResponse.statusCode): \(msg)")
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
return nil
}
let labels = try JSONDecoder().decode([BookmarkLabelDto].self, from: data)
logger.info("Successfully fetched \(labels.count) bookmark labels")
return labels
} catch {
logger.logNetworkError(method: "GET", url: "/api/bookmarks/labels", error: error)
showStatus("Network error: \(error.localizedDescription)", true)
return nil
}

View File

@ -436,7 +436,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -469,7 +469,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -624,7 +624,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -668,7 +668,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;

View File

@ -23,8 +23,9 @@ protocol PAPI {
class API: PAPI {
let tokenProvider: TokenProvider
private var cachedBaseURL: String?
private let logger = Logger.network
init(tokenProvider: TokenProvider = CoreDataTokenProvider()) {
init(tokenProvider: TokenProvider = KeychainTokenProvider()) {
self.tokenProvider = tokenProvider
}
@ -73,7 +74,6 @@ class API: PAPI {
}
guard 200...299 ~= httpResponse.statusCode else {
print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")")
throw APIError.serverError(httpResponse.statusCode)
}
@ -114,7 +114,6 @@ class API: PAPI {
}
guard 200...299 ~= httpResponse.statusCode else {
print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")")
throw APIError.serverError(httpResponse.statusCode)
}
@ -159,7 +158,11 @@ class API: PAPI {
}
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 requestData = try JSONEncoder().encode(loginRequest)
@ -168,20 +171,27 @@ class API: PAPI {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = requestData
logger.logNetworkRequest(method: "POST", 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 login request")
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
logger.logNetworkError(method: "POST", url: url.absoluteString, error: 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)
}
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 queryItems: [URLQueryItem] = []
@ -227,11 +237,16 @@ class API: PAPI {
endpoint += "?\(queryString)"
}
logger.logNetworkRequest(method: "GET", url: await self.baseURL + (endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)"))
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
endpoint: endpoint,
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
let currentPage = response.value(forHTTPHeaderField: "Current-Page").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 {
return try await makeJSONRequest(
endpoint: "/api/bookmarks/\(id)",
logger.debug("Fetching bookmark: \(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
)
logger.info("Successfully fetched bookmark: \(id)")
return result
}
// Artikel als String laden statt als JSON
func getBookmarkArticle(id: String) async throws -> String {
return try await makeStringRequest(
endpoint: "/api/bookmarks/\(id)/article"
logger.debug("Fetching article for bookmark: \(id)")
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 {
logger.info("Creating bookmark for URL: \(createRequest.url)")
let requestData = try JSONEncoder().encode(createRequest)
let endpoint = "/api/bookmarks"
logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint)
return try await makeJSONRequest(
endpoint: "/api/bookmarks",
let result = try await makeJSONRequest(
endpoint: endpoint,
method: .POST,
body: requestData,
responseType: CreateBookmarkResponseDto.self
)
logger.info("Successfully created bookmark: \(result.status)")
return result
}
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws {
logger.info("Updating bookmark: \(id)")
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 fullEndpoint = "/api/bookmarks/\(id)"
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
throw APIError.invalidURL
}
@ -295,24 +332,32 @@ class API: PAPI {
request.httpBody = requestData
logger.logNetworkRequest(method: "PATCH", 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 PATCH \(url.absoluteString)")
throw APIError.invalidResponse
}
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)
}
logger.logNetworkRequest(method: "PATCH", url: url.absoluteString, statusCode: httpResponse.statusCode)
logger.info("Successfully updated bookmark: \(id)")
}
func deleteBookmark(id: String) async throws {
// DELETE Request ohne Response-Body erwarten
logger.info("Deleting bookmark: \(id)")
let baseURL = await self.baseURL
let fullEndpoint = "/api/bookmarks/\(id)"
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
throw APIError.invalidURL
}
@ -324,25 +369,37 @@ class API: PAPI {
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 {
print("Server Error: \(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 bookmark: \(id)")
}
func searchBookmarks(search: String) async throws -> BookmarksPageDto {
logger.debug("Searching bookmarks with query: \(search)")
let endpoint = "/api/bookmarks?search=\(search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
endpoint: endpoint,
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 totalCount = response.value(forHTTPHeaderField: "Total-Count").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] {
return try await makeJSONRequest(
endpoint: "/api/bookmarks/labels",
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
}
}

View File

@ -4,22 +4,43 @@ import Foundation
class CoreDataManager {
static let shared = CoreDataManager()
private var isInMemoryStore = false
private let logger = Logger.data
private init() {}
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
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite")
if let storeURL = storeURL {
// Migrate existing database from app container to app group if needed
migrateStoreToAppGroupIfNeeded(targetURL: storeURL)
let storeDescription = NSPersistentStoreDescription(url: storeURL)
container.persistentStoreDescriptions = [storeDescription]
}
container.loadPersistentStores { _, error in
container.loadPersistentStores { [weak self] _, error in
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
@ -33,9 +54,126 @@ class CoreDataManager {
if context.hasChanges {
do {
try context.save()
logger.debug("Core Data context saved successfully")
} 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")
}
@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
@discardableResult
private func saveString(_ value: String, forKey key: String) -> Bool {

View File

@ -29,6 +29,15 @@ class LabelsRepository: PLabelsRepository {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
return (try? coreDataManager.context.fetch(fetchRequest).isEmpty == false) ?? false
var exists = false
coreDataManager.context.performAndWait {
do {
let results = try coreDataManager.context.fetch(fetchRequest)
exists = !results.isEmpty
} catch {
exists = false
}
}
return exists
}
}

View File

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

View File

@ -7,66 +7,22 @@ protocol TokenProvider {
func clearToken() async
}
class CoreDataTokenProvider: TokenProvider {
private let settingsRepository = SettingsRepository()
private var cachedSettings: Settings?
private var isLoaded = false
class KeychainTokenProvider: TokenProvider {
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? {
await loadSettingsIfNeeded()
return cachedSettings?.token
return keychainHelper.loadToken()
}
func getEndpoint() async -> String? {
await loadSettingsIfNeeded()
// Basis-URL ohne /api Suffix, da es in der API-Klasse hinzugefügt wird
return cachedSettings?.endpoint
return keychainHelper.loadEndpoint()
}
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)
}
func loadTokenFromKeychain() -> String? {
keychainHelper.loadToken()
func clearToken() async {
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 {
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
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(enableTTS: Bool) async throws
func execute(theme: Theme) async throws
@ -16,35 +13,6 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
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 {
try await settingsRepository.saveSettings(
.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

@ -12,36 +12,40 @@ class BookmarkLabelsViewModel {
var showErrorAlert = false
var currentLabels: [String] = [] {
didSet {
calculatePages()
if oldValue != currentLabels {
calculatePages()
}
}
}
var newLabelText = ""
var searchText = "" {
didSet {
calculatePages()
if oldValue != searchText {
calculatePages()
}
}
}
var allLabels: [BookmarkLabel] = [] {
didSet {
calculatePages()
if oldValue != allLabels {
calculatePages()
}
}
}
var labelPages: [[BookmarkLabel]] = []
// Computed property for available labels (excluding current labels)
// Cached properties to avoid recomputation
private var _availableLabels: [BookmarkLabel] = []
private var _filteredLabels: [BookmarkLabel] = []
var availableLabels: [BookmarkLabel] {
return allLabels.filter { currentLabels.contains($0.name) == false }
return _availableLabels
}
// Computed property for filtered labels based on search text
var filteredLabels: [BookmarkLabel] {
if searchText.isEmpty {
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
return _filteredLabels
}
var availableLabelPages: [[BookmarkLabel]] = []
@ -76,8 +80,8 @@ class BookmarkLabelsViewModel {
errorMessage = nil
do {
currentLabels.append(contentsOf: labels)
currentLabels = Array(Set(currentLabels)) // Remove duplicates
let uniqueLabels = Set(currentLabels + labels)
currentLabels = currentLabels.filter { uniqueLabels.contains($0) } + labels.filter { !currentLabels.contains($0) }
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
} catch let error as BookmarkUpdateError {
@ -89,7 +93,6 @@ class BookmarkLabelsViewModel {
}
isLoading = false
calculatePages()
}
@MainActor
@ -120,7 +123,6 @@ class BookmarkLabelsViewModel {
}
isLoading = false
calculatePages()
}
@MainActor
@ -136,8 +138,6 @@ class BookmarkLabelsViewModel {
} else {
await addLabel(to: bookmarkId, label: label)
}
calculatePages()
}
func updateLabels(_ labels: [String]) {
@ -147,24 +147,31 @@ class BookmarkLabelsViewModel {
private func calculatePages() {
let pageSize = Constants.Labels.pageSize
// Update cached available labels
_availableLabels = allLabels.filter { !currentLabels.contains($0.name) }
// Update cached filtered labels
if searchText.isEmpty {
_filteredLabels = _availableLabels
} else {
_filteredLabels = _availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
// Calculate pages for all labels
if allLabels.count <= pageSize {
labelPages = [allLabels]
} else {
// Normal pagination for larger datasets
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
}
}
// Calculate pages for filtered labels (search results or available labels)
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
if labelsToShow.count <= pageSize {
availableLabelPages = [labelsToShow]
// Calculate pages for filtered labels
if _filteredLabels.count <= pageSize {
availableLabelPages = [_filteredLabels]
} else {
// Normal pagination for larger datasets
availableLabelPages = stride(from: 0, to: labelsToShow.count, by: pageSize).map {
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
availableLabelPages = stride(from: 0, to: _filteredLabels.count, by: pageSize).map {
Array(_filteredLabels[$0..<min($0 + pageSize, _filteredLabels.count)])
}
}
}

View File

@ -39,124 +39,15 @@ struct BookmarksView: View {
var body: some View {
ZStack {
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
VStack(spacing: 20) {
Spacer()
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.3)
.tint(.accentColor)
VStack(spacing: 8) {
Text("Loading \(state.displayName)")
.font(.headline)
.foregroundColor(.primary)
Text("Please wait while we fetch your bookmarks...")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 40)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(R.color.bookmark_list_bg))
if shouldShowCenteredState {
centeredStateView
} else {
List {
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
Button(action: {
if UIDevice.isPhone {
selectedBookmarkId = bookmark.id
} else {
if selectedBookmark?.id == bookmark.id {
selectedBookmark = nil
DispatchQueue.main.async {
selectedBookmark = bookmark
}
} else {
selectedBookmark = bookmark
}
}
}) {
BookmarkCardView(
bookmark: bookmark,
currentState: state,
onArchive: { bookmark in
Task {
await viewModel.toggleArchive(bookmark: bookmark)
}
},
onDelete: { bookmark in
bookmarkToDelete = bookmark
},
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
},
namespace: namespace
)
.onAppear {
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
Task {
await viewModel.loadMoreBookmarks()
}
}
}
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color(R.color.bookmark_list_bg))
.matchedTransitionSource(id: bookmark.id, in: namespace)
}
}
.listStyle(.plain)
.background(Color(R.color.bookmark_list_bg))
.scrollContentBackground(.hidden)
.refreshable {
await viewModel.refreshBookmarks()
}
.overlay {
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading {
ContentUnavailableView(
"No bookmarks",
systemImage: "bookmark",
description: Text(
"No bookmarks found in \(state.displayName.lowercased())."
)
)
}
}
bookmarksList
}
// FAB Button - only show for "Unread"
if state == .unread || state == .all {
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
showingAddBookmark = true
}) {
Image(systemName: "plus")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Color.accentColor)
.clipShape(Circle())
.shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
}
// FAB Button - only show for "Unread" and when not in error/loading state
if (state == .unread || state == .all) && !shouldShowCenteredState {
fabButton
}
}
.navigationDestination(
@ -206,6 +97,189 @@ struct BookmarksView: View {
}
}
}
// MARK: - Computed Properties
private var shouldShowCenteredState: Bool {
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
return isEmpty && (viewModel.isLoading || viewModel.errorMessage != nil)
}
// MARK: - View Components
@ViewBuilder
private var centeredStateView: some View {
VStack(spacing: 20) {
Spacer()
if viewModel.isLoading {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(R.color.bookmark_list_bg))
}
@ViewBuilder
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.3)
.tint(.accentColor)
VStack(spacing: 8) {
Text("Loading \(state.displayName)")
.font(.headline)
.foregroundColor(.primary)
Text("Please wait while we fetch your bookmarks...")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 40)
}
@ViewBuilder
private func errorView(message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundColor(.orange)
VStack(spacing: 8) {
Text("Unable to load bookmarks")
.font(.headline)
.foregroundColor(.primary)
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Button("Try Again") {
Task {
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding(.horizontal, 40)
}
@ViewBuilder
private var bookmarksList: some View {
List {
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
Button(action: {
if UIDevice.isPhone {
selectedBookmarkId = bookmark.id
} else {
if selectedBookmark?.id == bookmark.id {
selectedBookmark = nil
DispatchQueue.main.async {
selectedBookmark = bookmark
}
} else {
selectedBookmark = bookmark
}
}
}) {
BookmarkCardView(
bookmark: bookmark,
currentState: state,
onArchive: { bookmark in
Task {
await viewModel.toggleArchive(bookmark: bookmark)
}
},
onDelete: { bookmark in
bookmarkToDelete = bookmark
},
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
},
namespace: namespace
)
.onAppear {
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
Task {
await viewModel.loadMoreBookmarks()
}
}
}
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color(R.color.bookmark_list_bg))
.matchedTransitionSource(id: bookmark.id, in: namespace)
}
// Show loading indicator for pagination
if viewModel.isLoading && !(viewModel.bookmarks?.bookmarks.isEmpty == true) {
HStack {
Spacer()
ProgressView()
.scaleEffect(0.8)
Spacer()
}
.listRowBackground(Color(R.color.bookmark_list_bg))
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.background(Color(R.color.bookmark_list_bg))
.scrollContentBackground(.hidden)
.refreshable {
await viewModel.refreshBookmarks()
}
.overlay {
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading && viewModel.errorMessage == nil {
ContentUnavailableView(
"No bookmarks",
systemImage: "bookmark",
description: Text(
"No bookmarks found in \(state.displayName.lowercased())."
)
)
}
}
}
@ViewBuilder
private var fabButton: some View {
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
showingAddBookmark = true
}) {
Image(systemName: "plus")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Color.accentColor)
.clipShape(Circle())
.shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
}
}
}
#Preview {

View File

@ -101,7 +101,7 @@ class BookmarksViewModel {
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available
} catch {
errorMessage = "Error loading bookmarks"
bookmarks = nil
// Don't clear bookmarks on error - keep existing data visible
}
isLoading = false

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

@ -191,7 +191,7 @@ struct TagManagementView: View {
.fontWeight(.medium)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(Array(selectedLabelsSet), id: \.self) { label in
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: false,

View File

@ -9,12 +9,23 @@ struct WebView: UIViewRepresentable {
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
let configuration = WKWebViewConfiguration()
// Enable text selection and copy functionality
let preferences = WKWebpagePreferences()
preferences.allowsContentJavaScript = true
configuration.defaultWebpagePreferences = preferences
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.scrollView.isScrollEnabled = false
webView.isOpaque = false
webView.backgroundColor = UIColor.clear
// Allow text selection and copying
webView.allowsBackForwardNavigationGestures = false
webView.allowsLinkPreview = true
// Message Handler hier einmalig hinzufügen
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
@ -66,6 +77,9 @@ struct WebView: UIViewRepresentable {
color: var(--text-color);
font-size: var(--base-font-size);
-webkit-text-size-adjust: 100%;
-webkit-user-select: text;
-webkit-touch-callout: default;
user-select: text;
}
h1, h2, h3, h4, h5, h6 {

View File

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

View File

@ -6,8 +6,13 @@
//
import Foundation
import Combine
class MockUseCaseFactory: UseCaseFactory {
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
MockOfflineBookmarkSyncUseCase()
}
func makeLoginUseCase() -> any PLoginUseCase {
MockLoginUserCase()
}
@ -181,6 +186,24 @@ class MockAddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase {
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 {
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)

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,158 @@
import Foundation
import SwiftUI
import Combine
@Observable
class OfflineBookmarksViewModel {
var state: OfflineBookmarkSyncState = .idle
private let syncUseCase: POfflineBookmarkSyncUseCase
private var cancellables = Set<AnyCancellable>()
private let successDelaySubject = PassthroughSubject<Int, Never>()
private var completionTimerActive = false
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
self.syncUseCase = syncUseCase
setupBindings()
refreshState()
}
// MARK: - Public Methods
func syncOfflineBookmarks() async {
guard case .pending(let count) = state else { return }
state = .syncing(count: count, status: nil)
await syncUseCase.syncOfflineBookmarks()
}
func refreshState() {
let currentCount = syncUseCase.getOfflineBookmarksCount()
updateStateWithCount(currentCount)
}
// MARK: - Private Setup
private func setupBindings() {
setupSyncBindings()
setupAppLifecycleBindings()
}
private func setupSyncBindings() {
syncUseCase.isSyncing
.receive(on: DispatchQueue.main)
.sink { [weak self] isSyncing in
self?.handleSyncingStateChange(isSyncing)
}
.store(in: &cancellables)
syncUseCase.syncStatus
.receive(on: DispatchQueue.main)
.sink { [weak self] status in
self?.handleSyncStatusUpdate(status)
}
.store(in: &cancellables)
// Auto-reset success state after 2 seconds
successDelaySubject
.delay(for: .seconds(2), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.state = .idle
}
.store(in: &cancellables)
}
private func setupAppLifecycleBindings() {
let foregroundPublisher = NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
let activePublisher = NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
Publishers.Merge(foregroundPublisher, activePublisher)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshState()
}
.store(in: &cancellables)
}
// MARK: - State Management
private func updateStateWithCount(_ count: Int) {
switch state {
case .idle:
if count > 0 {
state = .pending(count: count)
}
case .pending:
state = count > 0 ? .pending(count: count) : .idle
case .syncing:
// Keep syncing state - will be updated by sync handlers
break
case .success:
// Success state is temporary - handled by timer
break
case .error:
state = count > 0 ? .pending(count: count) : .idle
}
}
// MARK: - Sync Event Handlers
private func handleSyncingStateChange(_ isSyncing: Bool) {
if isSyncing {
transitionToSyncingIfPending()
} else {
// Only handle completion if we were actually syncing
if case .syncing = state {
handleSyncCompletion()
}
}
}
private func transitionToSyncingIfPending() {
if case .pending(let count) = state {
state = .syncing(count: count, status: nil)
}
}
private func handleSyncCompletion() {
guard !completionTimerActive else {
return
}
completionTimerActive = true
// wait for 0.5 seconds
Timer.publish(every: 0.5, on: .main, in: .common)
.autoconnect()
.first()
.sink { [weak self] _ in
guard let self = self else { return }
self.completionTimerActive = false
guard case .syncing(let originalCount, _) = self.state else {
return
}
let remainingCount = self.syncUseCase.getOfflineBookmarksCount()
if remainingCount == 0 {
self.state = .success(syncedCount: originalCount)
self.successDelaySubject.send(originalCount)
} else {
self.state = .pending(count: remainingCount)
}
}
.store(in: &cancellables)
}
private func handleSyncStatusUpdate(_ status: String?) {
if case .syncing(let count, _) = state {
state = .syncing(count: count, status: status)
}
}
deinit {
cancellables.removeAll()
}
}

View File

@ -13,6 +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())
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))
}
}
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))
.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 selectedTabIndex: Int = 1
@StateObject private var syncManager = OfflineSyncManager.shared
@State private var phoneTabLocalBookmarkCount = 0
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
@EnvironmentObject var appSettings: AppSettings
@ -25,31 +24,9 @@ struct PhoneTabView: View {
moreTabContent
}
.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
@ -78,7 +55,7 @@ struct PhoneTabView: View {
.tabItem {
Label("More", systemImage: "ellipsis")
}
.badge(phoneTabLocalBookmarkCount > 0 ? phoneTabLocalBookmarkCount : 0)
.badge(offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0)
.tag(mainTabs.count)
.onAppear {
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
@ -106,10 +83,14 @@ struct PhoneTabView: View {
.listRowBackground(Color(R.color.bookmark_list_bg))
}
if phoneTabLocalBookmarkCount > 0 {
if case .idle = offlineBookmarksViewModel.state {
// Don't show anything for idle state
} else {
Section {
VStack {
LocalBookmarksSyncView(bookmarkCount: phoneTabLocalBookmarkCount)
LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) {
await offlineBookmarksViewModel.syncOfflineBookmarks()
}
}
.listRowBackground(Color.clear)
.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()
.cardStyle()
// Debug-only Logging Configuration
if Bundle.main.isDebugBuild {
debugSettingsSection
}
}
.padding()
.background(Color(.systemGroupedBackground))
@ -39,6 +44,52 @@ struct SettingsContainerView: View {
.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
func AppInfo() -> some View {
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
if let errorMessage = viewModel.errorMessage {
HStack {

View File

@ -1,5 +1,5 @@
<?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">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="tags" optional="YES" attributeType="String"/>
@ -52,13 +52,10 @@
</entity>
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
<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="fontSize" optional="YES" attributeType="String"/>
<attribute name="password" optional="YES" attributeType="String"/>
<attribute name="theme" optional="YES" attributeType="String"/>
<attribute name="token" optional="YES" attributeType="String"/>
<attribute name="username" optional="YES" attributeType="String"/>
</entity>
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/>