From c8368f0a70a1f0b9cb2b7eceb9e653daf1e5026a Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 11 Jun 2025 22:02:44 +0200 Subject: [PATCH] feat: Implement bookmark filtering, enhanced UI, and API integration - Add BookmarkState enum with unread, favorite, and archived states - Extend API layer with query parameter filtering for bookmark states - Update Bookmark domain model to match complete API response schema - Implement BookmarkListView with card-based UI and preview images - Add BookmarkListViewModel with state management and error handling - Enhance BookmarkDetailView with meta information and WebView rendering - Create comprehensive DTO mapping for all bookmark fields - Add TabView with state-based bookmark filtering - Implement date formatting utilities for ISO8601 timestamps - Add progress indicators and pull-to-refresh functionality --- .../data.mdb | Bin .../lock.mdb | Bin 8256 -> 8256 bytes readeck/Data/API/API.swift | 247 ++++++++++-------- readeck/Data/API/DTOs/BookmarkDetailDto.swift | 4 + readeck/Data/API/DTOs/BookmarkDto.swift | 64 +++++ readeck/Data/API/DTOs/LoginRequestDto.swift | 7 + readeck/Data/CoreData/CoreDataManager.swift | 32 +++ readeck/Data/DTOs/BookmarkDetailDto.swift | 82 ------ readeck/Data/DTOs/BookmarkDto.swift | 15 -- readeck/Data/Mappers/BookmarkMapper.swift | 61 +++++ readeck/Data/Repository/AuthRepository.swift | 18 +- .../Data/Repository/BookmarksRepository.swift | 53 +++- .../Data/Repository/SettingsRepository.swift | 136 ++++++++++ readeck/Data/TokenManager.swift | 45 ++++ readeck/Data/TokenProvider.swift | 59 +++++ readeck/Domain/DefaultUseCaseFactory.swift | 42 ++- readeck/Domain/Model/Bookmark.swift | 49 ++++ .../Domain/Protocols/PAuthRepository.swift | 2 + .../UseCase/GetBookmarkArticleUseCase.swift | 13 + .../Domain/UseCase/GetBookmarkUseCase.swift | 13 + .../Domain/UseCase/GetBookmarksUseCase.swift | 31 ++- .../Domain/UseCase/LoadSettingsUseCase.swift | 13 + .../Domain/UseCase/SaveSettingsUseCase.swift | 19 ++ .../BookmarkDetail/BookmarkDetailView.swift | 119 +++++++++ .../BookmarkDetailViewModel.swift | 83 ++++++ readeck/UI/Bookmarks/BookmarkCardView.swift | 112 ++++++++ readeck/UI/Bookmarks/BookmarksView.swift | 38 +-- readeck/UI/Bookmarks/BookmarksViewModel.swift | 19 +- readeck/UI/Components/StatView.swift | 18 ++ readeck/UI/Components/WebView.swift | 135 ++++++++++ readeck/UI/Settings/SettingsView.swift | 91 +++++-- readeck/UI/Settings/SettingsViewModel.swift | 101 +++++-- readeck/UI/TabView.swift | 56 +++- readeck/UI/readeckApp.swift | 1 - .../readeck.xcdatamodel/contents | 11 +- 35 files changed, 1483 insertions(+), 306 deletions(-) rename .build/arm64-apple-macosx/debug/index/db/v13/{p26295--9cf7a9 => p4743--3eba9d}/data.mdb (100%) rename .build/arm64-apple-macosx/debug/index/db/v13/{p26295--9cf7a9 => p4743--3eba9d}/lock.mdb (96%) create mode 100644 readeck/Data/API/DTOs/BookmarkDetailDto.swift create mode 100644 readeck/Data/API/DTOs/BookmarkDto.swift create mode 100644 readeck/Data/API/DTOs/LoginRequestDto.swift create mode 100644 readeck/Data/CoreData/CoreDataManager.swift delete mode 100644 readeck/Data/DTOs/BookmarkDetailDto.swift delete mode 100644 readeck/Data/DTOs/BookmarkDto.swift create mode 100644 readeck/Data/Mappers/BookmarkMapper.swift create mode 100644 readeck/Data/Repository/SettingsRepository.swift create mode 100644 readeck/Data/TokenManager.swift create mode 100644 readeck/Data/TokenProvider.swift create mode 100644 readeck/Domain/Model/Bookmark.swift create mode 100644 readeck/Domain/UseCase/GetBookmarkArticleUseCase.swift create mode 100644 readeck/Domain/UseCase/GetBookmarkUseCase.swift create mode 100644 readeck/Domain/UseCase/LoadSettingsUseCase.swift create mode 100644 readeck/Domain/UseCase/SaveSettingsUseCase.swift create mode 100644 readeck/UI/BookmarkDetail/BookmarkDetailView.swift create mode 100644 readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift create mode 100644 readeck/UI/Bookmarks/BookmarkCardView.swift create mode 100644 readeck/UI/Components/StatView.swift create mode 100644 readeck/UI/Components/WebView.swift diff --git a/.build/arm64-apple-macosx/debug/index/db/v13/p26295--9cf7a9/data.mdb b/.build/arm64-apple-macosx/debug/index/db/v13/p4743--3eba9d/data.mdb similarity index 100% rename from .build/arm64-apple-macosx/debug/index/db/v13/p26295--9cf7a9/data.mdb rename to .build/arm64-apple-macosx/debug/index/db/v13/p4743--3eba9d/data.mdb diff --git a/.build/arm64-apple-macosx/debug/index/db/v13/p26295--9cf7a9/lock.mdb b/.build/arm64-apple-macosx/debug/index/db/v13/p4743--3eba9d/lock.mdb similarity index 96% rename from .build/arm64-apple-macosx/debug/index/db/v13/p26295--9cf7a9/lock.mdb rename to .build/arm64-apple-macosx/debug/index/db/v13/p4743--3eba9d/lock.mdb index 0e9fc674ae936d479b9827af05f5fd3d7cc67daf..493a42fd69f88c061f6f4b6b02f1a849dd369c6d 100644 GIT binary patch delta 69 zcmX@$aKM4{-hub~m= UserDto - func getBookmarks() async throws -> [BookmarkDto] + func getBookmarks(state: BookmarkState?) async throws -> [BookmarkDto] func getBookmark(id: String) async throws -> BookmarkDetailDto func getBookmarkArticle(id: String) async throws -> String - var authToken: String? { get set } } class API: PAPI { - private let baseURL: String - var authToken: String? + let tokenProvider: TokenProvider + private var cachedBaseURL: String? - init(baseURL: String) { - self.baseURL = baseURL + init(tokenProvider: TokenProvider = CoreDataTokenProvider()) { + self.tokenProvider = tokenProvider + } + + private var baseURL: String { + get async { + if let cached = cachedBaseURL { + return cached + } + let url = await tokenProvider.getEndpoint() + cachedBaseURL = url + return url + } + } + + // Separate Methode für JSON-Requests + private func makeJSONRequest( + endpoint: String, + method: HTTPMethod = .GET, + body: Data? = nil, + responseType: T.Type + ) async throws -> T { + let baseURL = await self.baseURL + let fullEndpoint = endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)" + + guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let token = await tokenProvider.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + if let body = body { + request.httpBody = body + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard 200...299 ~= httpResponse.statusCode else { + print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")") + throw APIError.serverError(httpResponse.statusCode) + } + + return try JSONDecoder().decode(T.self, from: data) + } + + // Separate Methode für String-Requests (HTML/Text) + private func makeStringRequest( + endpoint: String, + method: HTTPMethod = .GET + ) async throws -> String { + let baseURL = await self.baseURL + let fullEndpoint = endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)" + + guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + if let token = await tokenProvider.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard 200...299 ~= httpResponse.statusCode else { + throw APIError.serverError(httpResponse.statusCode) + } + + // Als String dekodieren statt als JSON + guard let string = String(data: data, encoding: .utf8) else { + throw APIError.invalidResponse + } + + return string } func login(username: String, password: String) async throws -> UserDto { - guard let url = URL(string: "\(baseURL)/auth") else { - throw APIError.invalidURL - } + let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password) + let requestData = try JSONEncoder().encode(loginRequest) - let credentials = [ - "username": username, - "password": password, - "application": "api doc" - ] + let userDto = try await makeJSONRequest( + endpoint: "/api/auth", + method: .POST, + body: requestData, + responseType: UserDto.self + ) - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "accept") - request.httpBody = try? JSONSerialization.data(withJSONObject: credentials) + // Token automatisch speichern nach erfolgreichem Login + await tokenProvider.setToken(userDto.token) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 201 else { - throw APIError.authenticationFailed - } - - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let token = json["token"] as? String { - self.authToken = token - let decoder = JSONDecoder() - return try decoder.decode(UserDto.self, from: data) - } - - throw APIError.invalidResponse + return userDto } - // Bookmarks abrufen - func getBookmarks() async throws -> [BookmarkDto] { - guard let url = URL(string: "\(baseURL)/bookmarks") else { - throw APIError.invalidURL + func getBookmarks(state: BookmarkState? = nil) async throws -> [BookmarkDto] { + var endpoint = "/api/bookmarks" + + // Query-Parameter basierend auf State hinzufügen + if let state = state { + switch state { + case .unread: + endpoint += "?is_archived=false&is_marked=false" + case .favorite: + endpoint += "?is_marked=true" + case .archived: + endpoint += "?is_archived=true" + } } - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("application/json", forHTTPHeaderField: "accept") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization") - } - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw APIError.networkError - } - - let decoder = JSONDecoder() - return try decoder.decode([BookmarkDto].self, from: data) + return try await makeJSONRequest( + endpoint: endpoint, + responseType: [BookmarkDto].self + ) } func getBookmark(id: String) async throws -> BookmarkDetailDto { - guard let url = URL(string: "\(baseURL)/bookmarks/\(id)") else { - throw APIError.invalidURL - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("application/json", forHTTPHeaderField: "accept") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization") - } - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw APIError.networkError - } - - let decoder = JSONDecoder() - return try decoder.decode(BookmarkDetailDto.self, from: data) + return try await makeJSONRequest( + endpoint: "/api/bookmarks/\(id)", + responseType: BookmarkDetailDto.self + ) } + // Artikel als String laden statt als JSON func getBookmarkArticle(id: String) async throws -> String { - guard let url = URL(string: "\(baseURL)/bookmarks/\(id)/article") else { - throw APIError.invalidURL - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("text/html", forHTTPHeaderField: "accept") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization") - } - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200, - let htmlContent = String(data: data, encoding: .utf8) else { - throw APIError.networkError - } - - return htmlContent + return try await makeStringRequest( + endpoint: "/api/bookmarks/\(id)/article" + ) } } + +enum HTTPMethod: String { + case GET = "GET" + case POST = "POST" + case PUT = "PUT" + case DELETE = "DELETE" +} + +enum APIError: Error { + case invalidURL + case invalidResponse + case serverError(Int) +} diff --git a/readeck/Data/API/DTOs/BookmarkDetailDto.swift b/readeck/Data/API/DTOs/BookmarkDetailDto.swift new file mode 100644 index 0000000..2a83575 --- /dev/null +++ b/readeck/Data/API/DTOs/BookmarkDetailDto.swift @@ -0,0 +1,4 @@ +import Foundation + +// BookmarkDetailDto ist identisch mit BookmarkDto +typealias BookmarkDetailDto = BookmarkDto \ No newline at end of file diff --git a/readeck/Data/API/DTOs/BookmarkDto.swift b/readeck/Data/API/DTOs/BookmarkDto.swift new file mode 100644 index 0000000..34778c0 --- /dev/null +++ b/readeck/Data/API/DTOs/BookmarkDto.swift @@ -0,0 +1,64 @@ +import Foundation + +struct BookmarkDto: Codable { + let id: String + let title: String + let url: String + let href: String + let description: String + let authors: [String] + let created: String + let published: String? + let updated: String + let siteName: String + let site: String + let readingTime: Int? + let wordCount: Int + let hasArticle: Bool + let isArchived: Bool + let isDeleted: Bool + let isMarked: Bool + let labels: [String] + let lang: String? + let loaded: Bool + let readProgress: Int + let documentType: String + let state: Int + let textDirection: String + let type: String + let resources: BookmarkResourcesDto + + enum CodingKeys: String, CodingKey { + case id, title, url, href, description, authors, created, published, updated, site, labels, lang, loaded, state, type + case siteName = "site_name" + case readingTime = "reading_time" + case wordCount = "word_count" + case hasArticle = "has_article" + case isArchived = "is_archived" + case isDeleted = "is_deleted" + case isMarked = "is_marked" + case readProgress = "read_progress" + case documentType = "document_type" + case textDirection = "text_direction" + case resources + } +} + +struct BookmarkResourcesDto: Codable { + let article: ResourceDto? + let icon: ImageResourceDto? + let image: ImageResourceDto? + let log: ResourceDto? + let props: ResourceDto? + let thumbnail: ImageResourceDto? +} + +struct ResourceDto: Codable { + let src: String +} + +struct ImageResourceDto: Codable { + let src: String + let height: Int + let width: Int +} diff --git a/readeck/Data/API/DTOs/LoginRequestDto.swift b/readeck/Data/API/DTOs/LoginRequestDto.swift new file mode 100644 index 0000000..ee2c406 --- /dev/null +++ b/readeck/Data/API/DTOs/LoginRequestDto.swift @@ -0,0 +1,7 @@ +import Foundation + +struct LoginRequestDto: Codable { + let application: String + let username: String + let password: String +} diff --git a/readeck/Data/CoreData/CoreDataManager.swift b/readeck/Data/CoreData/CoreDataManager.swift new file mode 100644 index 0000000..ff566fe --- /dev/null +++ b/readeck/Data/CoreData/CoreDataManager.swift @@ -0,0 +1,32 @@ +import CoreData +import Foundation + +class CoreDataManager { + static let shared = CoreDataManager() + + private init() {} + + lazy var persistentContainer: NSPersistentContainer = { + let container = NSPersistentContainer(name: "readeck") + container.loadPersistentStores { _, error in + if let error = error { + fatalError("Core Data error: \(error)") + } + } + return container + }() + + var context: NSManagedObjectContext { + return persistentContainer.viewContext + } + + func save() { + if context.hasChanges { + do { + try context.save() + } catch { + print("Failed to save Core Data context: \(error)") + } + } + } +} diff --git a/readeck/Data/DTOs/BookmarkDetailDto.swift b/readeck/Data/DTOs/BookmarkDetailDto.swift deleted file mode 100644 index 10236d9..0000000 --- a/readeck/Data/DTOs/BookmarkDetailDto.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -struct BookmarkDetailDto: Codable { - let id: String - let href: String - let created: String - let updated: String - let state: Int - let loaded: Bool - let url: String - let title: String - let siteName: String - let site: String - let authors: [String] - let lang: String - let textDirection: String - let documentType: String - let type: String - let hasArticle: Bool - let description: String - let isDeleted: Bool - let isMarked: Bool - let isArchived: Bool - let labels: [String] - let readProgress: Int - let resources: Resources - let links: [Link] - let wordCount: Int - let readingTime: Int - - enum CodingKeys: String, CodingKey { - case id, href, created, updated, state, loaded, url, title - case siteName = "site_name" - case site, authors, lang - case textDirection = "text_direction" - case documentType = "document_type" - case type - case hasArticle = "has_article" - case description - case isDeleted = "is_deleted" - case isMarked = "is_marked" - case isArchived = "is_archived" - case labels - case readProgress = "read_progress" - case resources, links - case wordCount = "word_count" - case readingTime = "reading_time" - } - - struct Resources: Codable { - let article: Resource - let icon: ResourceWithDimensions - let image: ResourceWithDimensions - let log: Resource - let props: Resource - let thumbnail: ResourceWithDimensions - } - - struct Resource: Codable { - let src: String - } - - struct ResourceWithDimensions: Codable { - let src: String - let width: Int - let height: Int - } - - struct Link: Codable { - let url: String - let domain: String - let title: String - let isPage: Bool - let contentType: String - - enum CodingKeys: String, CodingKey { - case url, domain, title - case isPage = "is_page" - case contentType = "content_type" - } - } -} \ No newline at end of file diff --git a/readeck/Data/DTOs/BookmarkDto.swift b/readeck/Data/DTOs/BookmarkDto.swift deleted file mode 100644 index cb79773..0000000 --- a/readeck/Data/DTOs/BookmarkDto.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -struct BookmarkDto: Codable { - let id: String - let title: String - let url: String - let createdAt: String - - enum CodingKeys: String, CodingKey { - case id - case title - case url - case createdAt = "created" - } -} \ No newline at end of file diff --git a/readeck/Data/Mappers/BookmarkMapper.swift b/readeck/Data/Mappers/BookmarkMapper.swift new file mode 100644 index 0000000..9efe225 --- /dev/null +++ b/readeck/Data/Mappers/BookmarkMapper.swift @@ -0,0 +1,61 @@ +import Foundation + +// MARK: - BookmarkDto to Domain Mapping +extension BookmarkDto { + func toDomain() -> Bookmark { + return Bookmark( + id: id, + title: title, + url: url, + href: href, + description: description, + authors: authors, + created: created, + published: published, + updated: updated, + siteName: siteName, + site: site, + readingTime: readingTime, + wordCount: wordCount, + hasArticle: hasArticle, + isArchived: isArchived, + isDeleted: isDeleted, + isMarked: isMarked, + labels: labels, + lang: lang, + loaded: loaded, + readProgress: readProgress, + documentType: documentType, + state: state, + textDirection: textDirection, + type: type, + resources: resources.toDomain() + ) + } +} + +// MARK: - Resources Mapping +extension BookmarkResourcesDto { + func toDomain() -> BookmarkResources { + return BookmarkResources( + article: article?.toDomain(), + icon: icon?.toDomain(), + image: image?.toDomain(), + log: log?.toDomain(), + props: props?.toDomain(), + thumbnail: thumbnail?.toDomain() + ) + } +} + +extension ResourceDto { + func toDomain() -> Resource { + return Resource(src: src) + } +} + +extension ImageResourceDto { + func toDomain() -> ImageResource { + return ImageResource(src: src, height: height, width: width) + } +} \ No newline at end of file diff --git a/readeck/Data/Repository/AuthRepository.swift b/readeck/Data/Repository/AuthRepository.swift index 8ab8351..484073a 100644 --- a/readeck/Data/Repository/AuthRepository.swift +++ b/readeck/Data/Repository/AuthRepository.swift @@ -2,22 +2,30 @@ import Foundation class AuthRepository: PAuthRepository { private let api: PAPI - - init(api: PAPI) { + private let settingsRepository: PSettingsRepository + + init(api: PAPI, settingsRepository: PSettingsRepository) { self.api = api + self.settingsRepository = settingsRepository } func login(username: String, password: String) async throws -> User { let userDto = try await api.login(username: username, password: password) - UserDefaults.standard.set(userDto.token, forKey: "token") - UserDefaults.standard.synchronize() + // Token wird automatisch von der API gespeichert return User(id: userDto.id, token: userDto.token) } func logout() async throws { - // Implement logout logic if needed + await api.tokenProvider.clearToken() } + func getCurrentSettings() async throws -> Settings? { + return try await settingsRepository.loadSettings() + } + + func saveSettings(_ settings: Settings) async throws { + try await settingsRepository.saveSettings(settings) + } } struct User { diff --git a/readeck/Data/Repository/BookmarksRepository.swift b/readeck/Data/Repository/BookmarksRepository.swift index a194ae2..a62390c 100644 --- a/readeck/Data/Repository/BookmarksRepository.swift +++ b/readeck/Data/Repository/BookmarksRepository.swift @@ -1,7 +1,9 @@ import Foundation protocol PBookmarksRepository { - func fetchBookmarks() async throws -> [Bookmark] + func fetchBookmarks(state: BookmarkState?) async throws -> [Bookmark] + func fetchBookmark(id: String) async throws -> BookmarkDetail + func fetchBookmarkArticle(id: String) async throws -> String func addBookmark(bookmark: Bookmark) async throws func removeBookmark(id: String) async throws } @@ -13,12 +15,34 @@ class BookmarksRepository: PBookmarksRepository { self.api = api } - func fetchBookmarks() async throws -> [Bookmark] { - let bookmarkDtos = try await api.getBookmarks() - api.authToken = UserDefaults.standard.string(forKey: "token") - return bookmarkDtos.map { dto in - Bookmark(id: dto.id, title: dto.title, url: dto.url, createdAt: dto.createdAt) - } + func fetchBookmarks(state: BookmarkState? = nil) async throws -> [Bookmark] { + let bookmarkDtos = try await api.getBookmarks(state: state) + return bookmarkDtos.map { $0.toDomain() } + } + + func fetchBookmark(id: String) async throws -> BookmarkDetail { + let bookmarkDetailDto = try await api.getBookmark(id: id) + return BookmarkDetail( + id: bookmarkDetailDto.id, + title: bookmarkDetailDto.title, + url: bookmarkDetailDto.url, + description: bookmarkDetailDto.description, + siteName: bookmarkDetailDto.siteName, + authors: bookmarkDetailDto.authors, + created: bookmarkDetailDto.created, + updated: bookmarkDetailDto.updated, + wordCount: bookmarkDetailDto.wordCount, + readingTime: bookmarkDetailDto.readingTime, + hasArticle: bookmarkDetailDto.hasArticle, + isMarked: bookmarkDetailDto.isMarked, + isArchived: bookmarkDetailDto.isArchived, + thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "", + imageUrl: bookmarkDetailDto.resources.image?.src ?? "" + ) + } + + func fetchBookmarkArticle(id: String) async throws -> String { + return try await api.getBookmarkArticle(id: id) } func addBookmark(bookmark: Bookmark) async throws { @@ -30,9 +54,20 @@ class BookmarksRepository: PBookmarksRepository { } } -struct Bookmark { +struct BookmarkDetail { let id: String let title: String let url: String - let createdAt: String + let description: String + let siteName: String + let authors: [String] + let created: String + let updated: String + let wordCount: Int + let readingTime: Int? + let hasArticle: Bool + let isMarked: Bool + let isArchived: Bool + let thumbnailUrl: String + let imageUrl: String } diff --git a/readeck/Data/Repository/SettingsRepository.swift b/readeck/Data/Repository/SettingsRepository.swift new file mode 100644 index 0000000..973e9d6 --- /dev/null +++ b/readeck/Data/Repository/SettingsRepository.swift @@ -0,0 +1,136 @@ +import Foundation +import CoreData + +struct Settings { + let endpoint: String + let username: String + let password: String + var token: String? + + var isLoggedIn: Bool { + token != nil && !token!.isEmpty + } + + mutating func setToken(_ newToken: String) { + token = newToken + } +} + +protocol PSettingsRepository { + func saveSettings(_ settings: Settings) async throws + func loadSettings() async throws -> Settings? + func clearSettings() async throws + func saveToken(_ token: String) async throws +} + +class SettingsRepository: PSettingsRepository { + private let coreDataManager = CoreDataManager.shared + + func saveSettings(_ settings: Settings) async throws { + let context = coreDataManager.context + + return try await withCheckedThrowingContinuation { continuation in + context.perform { + do { + // Vorhandene Einstellungen löschen + let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() + let existingSettings = try context.fetch(fetchRequest) + for setting in existingSettings { + context.delete(setting) + } + + // Neue Einstellungen erstellen + let settingEntity = SettingEntity(context: context) + settingEntity.endpoint = settings.endpoint + settingEntity.username = settings.username + settingEntity.password = settings.password + settingEntity.token = settings.token + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func loadSettings() async throws -> Settings? { + let context = coreDataManager.context + + return try await withCheckedThrowingContinuation { continuation in + context.perform { + do { + let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() + fetchRequest.fetchLimit = 1 + + let settingEntities = try context.fetch(fetchRequest) + + if let settingEntity = settingEntities.first { + let settings = Settings( + endpoint: settingEntity.endpoint ?? "", + username: settingEntity.username ?? "", + password: settingEntity.password ?? "", + token: settingEntity.token + ) + continuation.resume(returning: settings) + } else { + continuation.resume(returning: nil) + } + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func clearSettings() async throws { + let context = coreDataManager.context + + return try await withCheckedThrowingContinuation { continuation in + context.perform { + do { + let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() + let settingEntities = try context.fetch(fetchRequest) + + for settingEntity in settingEntities { + context.delete(settingEntity) + } + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func saveToken(_ token: String) async throws { + let context = coreDataManager.context + + return try await withCheckedThrowingContinuation { continuation in + context.perform { + do { + let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() + fetchRequest.fetchLimit = 1 + + let settingEntities = try context.fetch(fetchRequest) + + if let settingEntity = settingEntities.first { + settingEntity.token = token + } else { + // Fallback: Neue Einstellung erstellen (sollte normalerweise nicht passieren) + let settingEntity = SettingEntity(context: context) + settingEntity.token = token + } + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } +} diff --git a/readeck/Data/TokenManager.swift b/readeck/Data/TokenManager.swift new file mode 100644 index 0000000..4b558d2 --- /dev/null +++ b/readeck/Data/TokenManager.swift @@ -0,0 +1,45 @@ +import Foundation + +@Observable +class TokenManager { + static let shared = TokenManager() + + private let settingsRepository = SettingsRepository() + private var cachedSettings: Settings? + + private init() {} + + var currentToken: String? { + cachedSettings?.token + } + + var currentEndpoint: String? { + cachedSettings?.endpoint + } + + func loadSettings() async { + do { + cachedSettings = try await settingsRepository.loadSettings() + } catch { + print("Failed to load settings: \(error)") + } + } + + func updateToken(_ token: String) async { + do { + try await settingsRepository.saveToken(token) + cachedSettings?.token = token + } catch { + print("Failed to save token: \(error)") + } + } + + func clearToken() async { + do { + try await settingsRepository.clearSettings() + cachedSettings = nil + } catch { + print("Failed to clear settings: \(error)") + } + } +} \ No newline at end of file diff --git a/readeck/Data/TokenProvider.swift b/readeck/Data/TokenProvider.swift new file mode 100644 index 0000000..6135d3c --- /dev/null +++ b/readeck/Data/TokenProvider.swift @@ -0,0 +1,59 @@ +import Foundation + +protocol TokenProvider { + func getToken() async -> String? + func getEndpoint() async -> String + func setToken(_ token: String) async + func clearToken() async +} + +class CoreDataTokenProvider: TokenProvider { + private let settingsRepository = SettingsRepository() + private var cachedSettings: Settings? + private var isLoaded = false + + private func loadSettingsIfNeeded() async { + guard !isLoaded 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 + } + + func getEndpoint() async -> String { + await loadSettingsIfNeeded() + // Basis-URL ohne /api Suffix, da es in der API-Klasse hinzugefügt wird + return cachedSettings?.endpoint ?? "https://keep.mnk.any64.de" + } + + func setToken(_ token: String) async { + await loadSettingsIfNeeded() + + do { + try await settingsRepository.saveToken(token) + if cachedSettings != nil { + cachedSettings!.token = token + } + } catch { + print("Failed to save token: \(error)") + } + } + + func clearToken() async { + do { + try await settingsRepository.clearSettings() + cachedSettings = nil + } catch { + print("Failed to clear settings: \(error)") + } + } +} \ No newline at end of file diff --git a/readeck/Domain/DefaultUseCaseFactory.swift b/readeck/Domain/DefaultUseCaseFactory.swift index 6aafb4d..19b1783 100644 --- a/readeck/Domain/DefaultUseCaseFactory.swift +++ b/readeck/Domain/DefaultUseCaseFactory.swift @@ -2,23 +2,49 @@ import Foundation protocol UseCaseFactory { func makeLoginUseCase() -> LoginUseCase - func makeGetBooksmarksUseCase() -> GetBooksmarksUseCase + func makeGetBookmarksUseCase() -> GetBookmarksUseCase + func makeGetBookmarkUseCase() -> GetBookmarkUseCase + func makeGetBookmarkArticleUseCase() -> GetBookmarkArticleUseCase + func makeSaveSettingsUseCase() -> SaveSettingsUseCase + func makeLoadSettingsUseCase() -> LoadSettingsUseCase } class DefaultUseCaseFactory: UseCaseFactory { - private let api: PAPI + private let tokenProvider = CoreDataTokenProvider() + 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) static let shared = DefaultUseCaseFactory() - init(api: PAPI = API(baseURL: "https://keep.mnk.any64.de/api")) { - self.api = api - } + private init() {} func makeLoginUseCase() -> LoginUseCase { - LoginUseCase(repository: AuthRepository(api: api)) + LoginUseCase(repository: authRepository) } - func makeGetBooksmarksUseCase() -> GetBooksmarksUseCase { - GetBooksmarksUseCase(repository: .init(api: api)) + func makeGetBookmarksUseCase() -> GetBookmarksUseCase { + GetBookmarksUseCase(repository: bookmarksRepository) + } + + func makeGetBookmarkUseCase() -> GetBookmarkUseCase { + GetBookmarkUseCase(repository: bookmarksRepository) + } + + func makeGetBookmarkArticleUseCase() -> GetBookmarkArticleUseCase { + GetBookmarkArticleUseCase(repository: bookmarksRepository) + } + + func makeSaveSettingsUseCase() -> SaveSettingsUseCase { + SaveSettingsUseCase(authRepository: authRepository) + } + + func makeLoadSettingsUseCase() -> LoadSettingsUseCase { + LoadSettingsUseCase(authRepository: authRepository) + } + + // Nicht mehr nötig - Token wird automatisch geladen + func refreshConfiguration() async { + // Optional: Cache löschen falls nötig } } diff --git a/readeck/Domain/Model/Bookmark.swift b/readeck/Domain/Model/Bookmark.swift new file mode 100644 index 0000000..c0f5fb3 --- /dev/null +++ b/readeck/Domain/Model/Bookmark.swift @@ -0,0 +1,49 @@ +import Foundation + +struct Bookmark { + let id: String + let title: String + let url: String + let href: String + let description: String + let authors: [String] + let created: String + let published: String? + let updated: String + let siteName: String + let site: String + let readingTime: Int? + let wordCount: Int + let hasArticle: Bool + let isArchived: Bool + let isDeleted: Bool + let isMarked: Bool + let labels: [String] + let lang: String? + let loaded: Bool + let readProgress: Int + let documentType: String + let state: Int + let textDirection: String + let type: String + let resources: BookmarkResources +} + +struct BookmarkResources { + let article: Resource? + let icon: ImageResource? + let image: ImageResource? + let log: Resource? + let props: Resource? + let thumbnail: ImageResource? +} + +struct Resource { + let src: String +} + +struct ImageResource { + let src: String + let height: Int + let width: Int +} diff --git a/readeck/Domain/Protocols/PAuthRepository.swift b/readeck/Domain/Protocols/PAuthRepository.swift index c0d049a..3ad50f2 100644 --- a/readeck/Domain/Protocols/PAuthRepository.swift +++ b/readeck/Domain/Protocols/PAuthRepository.swift @@ -9,4 +9,6 @@ protocol PAuthRepository { func login(username: String, password: String) async throws -> User func logout() async throws + func getCurrentSettings() async throws -> Settings? + func saveSettings(_ settings: Settings) async throws } diff --git a/readeck/Domain/UseCase/GetBookmarkArticleUseCase.swift b/readeck/Domain/UseCase/GetBookmarkArticleUseCase.swift new file mode 100644 index 0000000..3f10957 --- /dev/null +++ b/readeck/Domain/UseCase/GetBookmarkArticleUseCase.swift @@ -0,0 +1,13 @@ +import Foundation + +class GetBookmarkArticleUseCase { + private let repository: PBookmarksRepository + + init(repository: PBookmarksRepository) { + self.repository = repository + } + + func execute(id: String) async throws -> String { + return try await repository.fetchBookmarkArticle(id: id) + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/GetBookmarkUseCase.swift b/readeck/Domain/UseCase/GetBookmarkUseCase.swift new file mode 100644 index 0000000..8449376 --- /dev/null +++ b/readeck/Domain/UseCase/GetBookmarkUseCase.swift @@ -0,0 +1,13 @@ +import Foundation + +class GetBookmarkUseCase { + private let repository: PBookmarksRepository + + init(repository: PBookmarksRepository) { + self.repository = repository + } + + func execute(id: String) async throws -> BookmarkDetail { + return try await repository.fetchBookmark(id: id) + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/GetBookmarksUseCase.swift b/readeck/Domain/UseCase/GetBookmarksUseCase.swift index 59ffc3a..2602ae8 100644 --- a/readeck/Domain/UseCase/GetBookmarksUseCase.swift +++ b/readeck/Domain/UseCase/GetBookmarksUseCase.swift @@ -1,12 +1,29 @@ +import Foundation -class GetBooksmarksUseCase { - private let repository: BookmarksRepository - - init(repository: BookmarksRepository) { +class GetBookmarksUseCase { + private let repository: PBookmarksRepository + + init(repository: PBookmarksRepository) { self.repository = repository } - - func execute() async throws -> [Bookmark] { - return try await repository.fetchBookmarks() + + func execute(state: BookmarkState? = nil) async throws -> [Bookmark] { + let allBookmarks = try await repository.fetchBookmarks(state: state) + + // Fallback-Filterung auf Client-Seite falls API keine Query-Parameter unterstützt + if let state = state { + return allBookmarks.filter { bookmark in + switch state { + case .unread: + return !bookmark.isArchived && !bookmark.isMarked + case .favorite: + return bookmark.isMarked + case .archived: + return bookmark.isArchived + } + } + } + + return allBookmarks } } diff --git a/readeck/Domain/UseCase/LoadSettingsUseCase.swift b/readeck/Domain/UseCase/LoadSettingsUseCase.swift new file mode 100644 index 0000000..36e297f --- /dev/null +++ b/readeck/Domain/UseCase/LoadSettingsUseCase.swift @@ -0,0 +1,13 @@ +import Foundation + +class LoadSettingsUseCase { + private let authRepository: PAuthRepository + + init(authRepository: PAuthRepository) { + self.authRepository = authRepository + } + + func execute() async throws -> Settings? { + return try await authRepository.getCurrentSettings() + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/SaveSettingsUseCase.swift b/readeck/Domain/UseCase/SaveSettingsUseCase.swift new file mode 100644 index 0000000..f9db546 --- /dev/null +++ b/readeck/Domain/UseCase/SaveSettingsUseCase.swift @@ -0,0 +1,19 @@ +import Foundation + +class SaveSettingsUseCase { + private let authRepository: PAuthRepository + + init(authRepository: PAuthRepository) { + self.authRepository = authRepository + } + + func execute(endpoint: String, username: String, password: String) async throws { + let settings = Settings( + endpoint: endpoint, + username: username, + password: password, + token: nil + ) + try await authRepository.saveSettings(settings) + } +} \ No newline at end of file diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift new file mode 100644 index 0000000..9898a73 --- /dev/null +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct BookmarkDetailView: View { + let bookmarkId: String + @State private var viewModel = BookmarkDetailViewModel() + @State private var webViewHeight: CGFloat = 300 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Header mit Bild + if !viewModel.bookmarkDetail.imageUrl.isEmpty { + AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 200) + } + .frame(height: 200) + .clipped() + } + + VStack(alignment: .leading, spacing: 6) { + // Titel + Text(viewModel.bookmarkDetail.title) + .font(.largeTitle) + .fontWeight(.bold) + + // Meta-Informationen + metaInfoSection + + Divider() + + // Artikel-Inhalt mit WebView + if !viewModel.articleContent.isEmpty { + WebView(htmlContent: viewModel.articleContent) { height in + webViewHeight = height + } + .frame(height: webViewHeight) + } else if viewModel.isLoadingArticle { + ProgressView("Lade Artikel...") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } + } + .padding() + } + } + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadBookmarkDetail(id: bookmarkId) + await viewModel.loadArticleContent(id: bookmarkId) + } + } + + private var metaInfoSection: some View { + VStack(alignment: .leading, spacing: 8) { + if !viewModel.bookmarkDetail.authors.isEmpty { + HStack { + Image(systemName: "person") + Text(viewModel.bookmarkDetail.authors.joined(separator: ", ")) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + HStack { + Image(systemName: "calendar") + Text("Erstellt: \(formatDate(viewModel.bookmarkDetail.created))") + .font(.subheadline) + .foregroundColor(.secondary) + } + + HStack { + Image(systemName: "textformat") + Text("\(viewModel.bookmarkDetail.wordCount) Wörter • \(viewModel.bookmarkDetail.readingTime) min Lesezeit") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + + private func formatDate(_ dateString: String) -> String { + // Erstelle einen Formatter für das ISO8601-Format mit Millisekunden + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + // Fallback für Format ohne Millisekunden + let isoFormatterNoMillis = ISO8601DateFormatter() + isoFormatterNoMillis.formatOptions = [.withInternetDateTime] + + // Versuche beide Formate + var date: Date? + if let parsedDate = isoFormatter.date(from: dateString) { + date = parsedDate + } else if let parsedDate = isoFormatterNoMillis.date(from: dateString) { + date = parsedDate + } + + if let date = date { + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .medium + displayFormatter.timeStyle = .short + displayFormatter.locale = Locale(identifier: "de_DE") + return displayFormatter.string(from: date) + } + + return dateString + } +} + +#Preview { + NavigationView { + BookmarkDetailView(bookmarkId: "sample-id") + } +} diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift new file mode 100644 index 0000000..1ef3999 --- /dev/null +++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift @@ -0,0 +1,83 @@ +import Foundation + +@Observable +class BookmarkDetailViewModel { + private let getBookmarkUseCase: GetBookmarkUseCase + private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase + + var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty + var articleContent: String = "" + var articleParagraphs: [String] = [] + var bookmark: Bookmark? = nil + var isLoading = false + var isLoadingArticle = false + var errorMessage: String? + + init() { + let factory = DefaultUseCaseFactory.shared + self.getBookmarkUseCase = factory.makeGetBookmarkUseCase() + self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() + } + + @MainActor + func loadBookmarkDetail(id: String) async { + isLoading = true + errorMessage = nil + + do { + bookmarkDetail = try await getBookmarkUseCase.execute(id: id) + + // Auch das vollständige Bookmark für readProgress laden + // (Falls GetBookmarkUseCase nur BookmarkDetail zurückgibt) + // Du könntest einen separaten UseCase für das vollständige Bookmark erstellen + + } catch { + errorMessage = "Fehler beim Laden des Bookmarks" + } + + isLoading = false + } + + @MainActor + func loadArticleContent(id: String) async { + isLoadingArticle = true + + do { + articleContent = try await getBookmarkArticleUseCase.execute(id: id) + processArticleContent() + } catch { + errorMessage = "Fehler beim Laden des Artikels" + } + + isLoadingArticle = false + } + + private func processArticleContent() { + // HTML in Paragraphen aufteilen + let paragraphs = articleContent + .components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + articleParagraphs = paragraphs + } +} + +extension BookmarkDetail { + static let empty = BookmarkDetail( + id: "", + title: "", + url: "", + description: "", + siteName: "", + authors: [], + created: "", + updated: "", + wordCount: 0, + readingTime: 0, + hasArticle: false, + isMarked: false, + isArchived: false, + thumbnailUrl: "", + imageUrl: "" + ) +} diff --git a/readeck/UI/Bookmarks/BookmarkCardView.swift b/readeck/UI/Bookmarks/BookmarkCardView.swift new file mode 100644 index 0000000..2b32300 --- /dev/null +++ b/readeck/UI/Bookmarks/BookmarkCardView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +struct BookmarkCardView: View { + let bookmark: Bookmark + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Vorschaubild - verwende image oder thumbnail + AsyncImage(url: imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.2)) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + } + } + .frame(height: 120) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 4) { + // Status-Badges + HStack { + if bookmark.isMarked { + Badge(text: "Markiert", color: .blue) + } + if bookmark.isArchived { + Badge(text: "Archiviert", color: .gray) + } + if bookmark.hasArticle { + Badge(text: "Artikel", color: .green) + } + Spacer() + } + + // Titel + Text(bookmark.title) + .font(.headline) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + + // Beschreibung + if !bookmark.description.isEmpty { + Text(bookmark.description) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(3) + .multilineTextAlignment(.leading) + } + + // Meta-Info + HStack { + if !bookmark.siteName.isEmpty { + Label(bookmark.siteName, systemImage: "globe") + } + + Spacer() + + if let readingTime = bookmark.readingTime, readingTime > 0 { + Label("\(readingTime) min", systemImage: "clock") + } + } + .font(.caption) + .foregroundColor(.secondary) + + // Progress Bar für Lesefortschritt + if bookmark.readProgress > 0 { + ProgressView(value: Double(bookmark.readProgress), total: 100) + .progressViewStyle(LinearProgressViewStyle()) + .frame(height: 4) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + } + + private var imageURL: URL? { + // Bevorzuge image, dann thumbnail, dann icon + if let imageUrl = bookmark.resources.image?.src { + return URL(string: imageUrl) + } else if let thumbnailUrl = bookmark.resources.thumbnail?.src { + return URL(string: thumbnailUrl) + } else if let iconUrl = bookmark.resources.icon?.src { + return URL(string: iconUrl) + } + return nil + } +} + +struct Badge: View { + let text: String + let color: Color + + var body: some View { + Text(text) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.2)) + .foregroundColor(color) + .clipShape(Capsule()) + } +} + diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index 37a1ff9..1103170 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -2,15 +2,19 @@ import SwiftUI struct BookmarksView: View { @State private var viewModel = BookmarksViewModel() + let state: BookmarkState var body: some View { NavigationView { ZStack { if viewModel.isLoading { ProgressView() - } else { + } else { + List(viewModel.bookmarks, id: \.id) { bookmark in - BookmarkRow(bookmark: bookmark) + NavigationLink(destination: BookmarkDetailView(bookmarkId: bookmark.id)) { + BookmarkCardView(bookmark: bookmark) + } } .refreshable { await viewModel.loadBookmarks() @@ -26,14 +30,14 @@ struct BookmarksView: View { } } } - .navigationTitle("Meine Bookmarks") + .navigationTitle(state.displayName) .alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) { Button("OK", role: .cancel) { } } message: { Text(viewModel.errorMessage ?? "") } .task { - await viewModel.loadBookmarks() + await viewModel.loadBookmarks(state: state) } } } @@ -44,18 +48,20 @@ private struct BookmarkRow: View { let bookmark: Bookmark var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(bookmark.title) - .font(.headline) - - Text(bookmark.url) - .font(.caption) - .foregroundColor(.secondary) - - Text(bookmark.createdAt) - .font(.caption2) - .foregroundColor(.secondary) + NavigationLink(destination: BookmarkDetailView(bookmarkId: bookmark.id)) { + VStack(alignment: .leading, spacing: 4) { + Text(bookmark.title) + .font(.headline) + + Text(bookmark.url) + .font(.caption) + .foregroundColor(.secondary) + + Text(bookmark.created) + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) } - .padding(.vertical, 4) } } diff --git a/readeck/UI/Bookmarks/BookmarksViewModel.swift b/readeck/UI/Bookmarks/BookmarksViewModel.swift index 79cff09..707898e 100644 --- a/readeck/UI/Bookmarks/BookmarksViewModel.swift +++ b/readeck/UI/Bookmarks/BookmarksViewModel.swift @@ -2,27 +2,36 @@ import Foundation @Observable class BookmarksViewModel { - private let getBooksmarksUseCase = DefaultUseCaseFactory.shared.makeGetBooksmarksUseCase() + private let getBooksmarksUseCase = DefaultUseCaseFactory.shared.makeGetBookmarksUseCase() var bookmarks: [Bookmark] = [] var isLoading = false var errorMessage: String? + var currentState: BookmarkState = .unread + init() { - + } @MainActor - func loadBookmarks() async { + func loadBookmarks(state: BookmarkState = .unread) async { isLoading = true errorMessage = nil - + currentState = state + do { - bookmarks = try await getBooksmarksUseCase.execute() + bookmarks = try await getBooksmarksUseCase.execute(state: state) } catch { errorMessage = "Fehler beim Laden der Bookmarks" + bookmarks = [] } isLoading = false } + + @MainActor + func refreshBookmarks() async { + await loadBookmarks(state: currentState) + } } diff --git a/readeck/UI/Components/StatView.swift b/readeck/UI/Components/StatView.swift new file mode 100644 index 0000000..cd7dc29 --- /dev/null +++ b/readeck/UI/Components/StatView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct StatView: View { + let title: String + let value: String + + var body: some View { + VStack(spacing: 4) { + Text(value) + .font(.headline) + .fontWeight(.semibold) + Text(title) + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + } +} \ No newline at end of file diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift new file mode 100644 index 0000000..2f9afae --- /dev/null +++ b/readeck/UI/Components/WebView.swift @@ -0,0 +1,135 @@ +import SwiftUI +import WebKit + +struct WebView: UIViewRepresentable { + let htmlContent: String + let onHeightChange: (CGFloat) -> Void + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.navigationDelegate = context.coordinator + webView.scrollView.isScrollEnabled = false + + // Message Handler hier einmalig hinzufügen + webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate") + context.coordinator.onHeightChange = onHeightChange + + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + // Nur den HTML-Inhalt laden, keine Handler-Konfiguration + context.coordinator.onHeightChange = onHeightChange + + let styledHTML = """ + + + + + + + \(htmlContent) + + + + """ + webView.loadHTMLString(styledHTML, baseURL: nil) + } + + func makeCoordinator() -> WebViewCoordinator { + WebViewCoordinator() + } +} + +class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var onHeightChange: ((CGFloat) -> Void)? + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if navigationAction.navigationType == .linkActivated { + if let url = navigationAction.request.url { + UIApplication.shared.open(url) + decisionHandler(.cancel) + return + } + } + decisionHandler(.allow) + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "heightUpdate", let height = message.body as? CGFloat { + DispatchQueue.main.async { + self.onHeightChange?(height) + } + } + } + + deinit { + // Der Message Handler wird automatisch mit der WebView entfernt + } +} \ No newline at end of file diff --git a/readeck/UI/Settings/SettingsView.swift b/readeck/UI/Settings/SettingsView.swift index 7a0c2a3..28e4187 100644 --- a/readeck/UI/Settings/SettingsView.swift +++ b/readeck/UI/Settings/SettingsView.swift @@ -6,47 +6,84 @@ struct SettingsView: View { var body: some View { NavigationView { Form { - Section(header: Text("Anmeldedaten"), footer: SectionFooter()) { + Section("Server-Einstellungen") { + TextField("Endpoint URL", text: $viewModel.endpoint) + .textContentType(.URL) + .keyboardType(.URL) + .autocapitalization(.none) + TextField("Benutzername", text: $viewModel.username) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) + .textContentType(.username) + .autocapitalization(.none) SecureField("Passwort", text: $viewModel.password) - - TextField("Endpoint", text: $viewModel.endpoint) - .keyboardType(.URL) - + .textContentType(.password) + } + + Section { + Button { + Task { + await viewModel.saveSettings() + } + } label: { + HStack { + if viewModel.isSaving { + ProgressView() + .scaleEffect(0.8) + } + Text("Einstellungen speichern") + } + } + .disabled(!viewModel.canSave || viewModel.isSaving) + } + + Section("Anmeldung") { Button { Task { await viewModel.login() } } label: { - if viewModel.isLoading { - ProgressView() - } else { - Text("Speichern") + HStack { + if viewModel.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text(viewModel.isLoggedIn ? "Erneut anmelden" : "Anmelden") + } + } + .disabled(!viewModel.canLogin || viewModel.isLoading) + + if viewModel.isLoggedIn { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Erfolgreich angemeldet") } } - .disabled(viewModel.isLoginDisabled) } - + // Success/Error Messages + if let successMessage = viewModel.successMessage { + Section { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(successMessage) + } + } + } } .navigationTitle("Einstellungen") - } - } - - @ViewBuilder - private func SectionFooter() -> some View { - switch viewModel.state { - case .error: - Text("Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingaben.") - .foregroundColor(.red) - case .success: - Text("Anmeldung erfolgreich!") - .foregroundColor(.green) - case .default: - Text("") + .alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK", role: .cancel) { + viewModel.errorMessage = nil + } + } message: { + Text(viewModel.errorMessage ?? "") + } + .task { + await viewModel.loadSettings() + } } } } diff --git a/readeck/UI/Settings/SettingsViewModel.swift b/readeck/UI/Settings/SettingsViewModel.swift index 8e9b153..887ff22 100644 --- a/readeck/UI/Settings/SettingsViewModel.swift +++ b/readeck/UI/Settings/SettingsViewModel.swift @@ -2,36 +2,103 @@ import Foundation @Observable class SettingsViewModel { + private let loginUseCase: LoginUseCase + private let saveSettingsUseCase: SaveSettingsUseCase + private let loadSettingsUseCase: LoadSettingsUseCase - enum State { - case `default`, error, success + var endpoint = "" + var username = "" + var password = "" + var isLoading = false + var isSaving = false + var isLoggedIn = false + var errorMessage: String? + var successMessage: String? + + init() { + let factory = DefaultUseCaseFactory.shared + self.loginUseCase = factory.makeLoginUseCase() + self.saveSettingsUseCase = factory.makeSaveSettingsUseCase() + self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() } - var username: String = "admin" - var password: String = "Diggah123" - var endpoint: String = "" - var isLoading: Bool = false - var state: State = .default - var showAlert: Bool = false + @MainActor + func loadSettings() async { + do { + if let settings = try await loadSettingsUseCase.execute() { + endpoint = settings.endpoint + username = settings.username + password = settings.password + isLoggedIn = settings.isLoggedIn // Verwendet die neue Hilfsmethode + } + } catch { + errorMessage = "Fehler beim Laden der Einstellungen" + } + } - private let loginUseCase = DefaultUseCaseFactory.shared.makeLoginUseCase() - - var isLoginDisabled: Bool { - username.isEmpty || password.isEmpty || isLoading + @MainActor + func saveSettings() async { + isSaving = true + errorMessage = nil + successMessage = nil + + do { + try await saveSettingsUseCase.execute( + endpoint: endpoint, + username: username, + password: password + ) + successMessage = "Einstellungen gespeichert" + + // Factory-Konfiguration aktualisieren + await DefaultUseCaseFactory.shared.refreshConfiguration() + + } catch { + errorMessage = "Fehler beim Speichern der Einstellungen" + } + + isSaving = false } @MainActor func login() async { isLoading = true + errorMessage = nil + successMessage = nil do { - let userResult = try await loginUseCase.execute(username: username, password: password) - state = userResult.token.isEmpty ? .error : .success - isLoading = false + _ = try await loginUseCase.execute(username: username, password: password) + isLoggedIn = true + successMessage = "Erfolgreich angemeldet" + + // Factory-Konfiguration aktualisieren (Token wird automatisch gespeichert) + await DefaultUseCaseFactory.shared.refreshConfiguration() + } catch { - state = .error - isLoading = false + errorMessage = "Anmeldung fehlgeschlagen" + isLoggedIn = false } + isLoading = false + } + + @MainActor + func logout() async { + do { + // Hier könntest du eine Logout-UseCase hinzufügen + // try await logoutUseCase.execute() + isLoggedIn = false + successMessage = "Abgemeldet" + } catch { + errorMessage = "Fehler beim Abmelden" + } + } + + var canSave: Bool { + !endpoint.isEmpty && !username.isEmpty && !password.isEmpty + } + + var canLogin: Bool { + !username.isEmpty && !password.isEmpty } } diff --git a/readeck/UI/TabView.swift b/readeck/UI/TabView.swift index b3732df..3a77c46 100644 --- a/readeck/UI/TabView.swift +++ b/readeck/UI/TabView.swift @@ -1,17 +1,57 @@ import SwiftUI +import Foundation + +enum BookmarkState: String, CaseIterable { + case unread = "unread" + case favorite = "favorite" + case archived = "archived" + + var displayName: String { + switch self { + case .unread: + return "Ungelesen" + case .favorite: + return "Favoriten" + case .archived: + return "Archiv" + } + } + + var systemImage: String { + switch self { + case .unread: + return "house" + case .favorite: + return "heart" + case .archived: + return "archivebox" + } + } +} struct MainTabView: View { - @State private var selectedTab: String = "Home" + @State private var selectedTab: String = "Ungelesen" var body: some View { - - TabView() { - BookmarksView() + TabView(selection: $selectedTab) { + BookmarksView(state: .unread) .tabItem { - Label("Links", systemImage: "house") + Label("Ungelesen", systemImage: "house") } - .tag("Home") + .tag("Ungelesen") + BookmarksView(state: .favorite) + .tabItem { + Label("Favoriten", systemImage: "heart") + } + .tag("Favoriten") + + BookmarksView(state: .archived) + .tabItem { + Label("Archiv", systemImage: "archivebox") + } + .tag("Archiv") + SettingsView() .tabItem { Label("Settings", systemImage: "gear") @@ -21,3 +61,7 @@ struct MainTabView: View { .accentColor(.blue) } } + +#Preview { + MainTabView() +} diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 586a232..2aee4a6 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -13,7 +13,6 @@ struct readeckApp: App { var body: some Scene { WindowGroup { - // ContentView() MainTabView() .environment(\.managedObjectContext, persistenceController.container.viewContext) } diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index 9ed2921..4fa8738 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -1,9 +1,12 @@ - + - - - + + + + + + \ No newline at end of file