From cd265730d3af5a7f41a03dddbeb81d161be244e4 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 11 Jun 2025 22:31:43 +0200 Subject: [PATCH] feat: Add bookmark actions (archive, favorite, delete) - Add PATCH API endpoint for updating bookmarks with toggle functions - Add DELETE API endpoint for permanent bookmark deletion - Implement UpdateBookmarkUseCase with convenience methods for common actions - Implement DeleteBookmarkUseCase for permanent bookmark removal - Create BookmarkUpdateRequest domain model with builder pattern - Extend BookmarkCardView with action menu and confirmation dialog - Add context-sensitive actions based on current bookmark state - Implement optimistic updates in BookmarksViewModel - Add error handling and recovery for failed operations - Enhance UI with badges, progress indicators, and action buttons --- readeck/Data/API/API.swift | 66 ++++++++++++++++++ .../API/DTOs/UpdateBookmarkRequestDto.swift | 25 +++++++ .../Data/Repository/BookmarksRepository.swift | 23 ++++++- readeck/Domain/DefaultUseCaseFactory.swift | 10 +++ .../Domain/Model/BookmarkUpdateRequest.swift | 62 +++++++++++++++++ .../UseCase/DeleteBookmarkUseCase.swift | 13 ++++ .../UseCase/UpdateBookmarkUseCase.swift | 44 ++++++++++++ readeck/UI/Bookmarks/BookmarkCardView.swift | 69 ++++++++++++++++--- readeck/UI/Bookmarks/BookmarksView.swift | 69 ++++++++++--------- readeck/UI/Bookmarks/BookmarksViewModel.swift | 50 ++++++++++++++ 10 files changed, 386 insertions(+), 45 deletions(-) create mode 100644 readeck/Data/API/DTOs/UpdateBookmarkRequestDto.swift create mode 100644 readeck/Domain/Model/BookmarkUpdateRequest.swift create mode 100644 readeck/Domain/UseCase/DeleteBookmarkUseCase.swift create mode 100644 readeck/Domain/UseCase/UpdateBookmarkUseCase.swift diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index d54b886..7bddd11 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -13,6 +13,8 @@ protocol PAPI { func getBookmarks(state: BookmarkState?) async throws -> [BookmarkDto] func getBookmark(id: String) async throws -> BookmarkDetailDto func getBookmarkArticle(id: String) async throws -> String + func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws + func deleteBookmark(id: String) async throws } class API: PAPI { @@ -162,12 +164,76 @@ class API: PAPI { endpoint: "/api/bookmarks/\(id)/article" ) } + + func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws { + let requestData = try JSONEncoder().encode(updateRequest) + + // PATCH Request ohne Response-Body erwarten + let baseURL = await self.baseURL + let fullEndpoint = "/api/bookmarks/\(id)" + + guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if let token = await tokenProvider.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + request.httpBody = requestData + + let (_, 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)") + throw APIError.serverError(httpResponse.statusCode) + } + } + + func deleteBookmark(id: String) async throws { + // DELETE Request ohne Response-Body erwarten + let baseURL = await self.baseURL + let fullEndpoint = "/api/bookmarks/\(id)" + + guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if let token = await tokenProvider.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let (_, 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)") + throw APIError.serverError(httpResponse.statusCode) + } + } } enum HTTPMethod: String { case GET = "GET" case POST = "POST" case PUT = "PUT" + case PATCH = "PATCH" case DELETE = "DELETE" } diff --git a/readeck/Data/API/DTOs/UpdateBookmarkRequestDto.swift b/readeck/Data/API/DTOs/UpdateBookmarkRequestDto.swift new file mode 100644 index 0000000..e739402 --- /dev/null +++ b/readeck/Data/API/DTOs/UpdateBookmarkRequestDto.swift @@ -0,0 +1,25 @@ +import Foundation + +struct UpdateBookmarkRequestDto: Codable { + let addLabels: [String]? + let isArchived: Bool? + let isDeleted: Bool? + let isMarked: Bool? + let labels: [String]? + let readAnchor: String? + let readProgress: Int? + let removeLabels: [String]? + let title: String? + + enum CodingKeys: String, CodingKey { + case addLabels = "add_labels" + case isArchived = "is_archived" + case isDeleted = "is_deleted" + case isMarked = "is_marked" + case labels + case readAnchor = "read_anchor" + case readProgress = "read_progress" + case removeLabels = "remove_labels" + case title + } +} \ No newline at end of file diff --git a/readeck/Data/Repository/BookmarksRepository.swift b/readeck/Data/Repository/BookmarksRepository.swift index a62390c..5de9e6e 100644 --- a/readeck/Data/Repository/BookmarksRepository.swift +++ b/readeck/Data/Repository/BookmarksRepository.swift @@ -4,8 +4,9 @@ protocol PBookmarksRepository { func fetchBookmarks(state: BookmarkState?) async throws -> [Bookmark] func fetchBookmark(id: String) async throws -> BookmarkDetail func fetchBookmarkArticle(id: String) async throws -> String + func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws + func deleteBookmark(id: String) async throws func addBookmark(bookmark: Bookmark) async throws - func removeBookmark(id: String) async throws } class BookmarksRepository: PBookmarksRepository { @@ -49,8 +50,24 @@ class BookmarksRepository: PBookmarksRepository { // Implement logic to add a bookmark if needed } - func removeBookmark(id: String) async throws { - // Implement logic to remove a bookmark if needed + func deleteBookmark(id: String) async throws { + try await api.deleteBookmark(id: id) + } + + func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws { + let dto = UpdateBookmarkRequestDto( + addLabels: updateRequest.addLabels, + isArchived: updateRequest.isArchived, + isDeleted: updateRequest.isDeleted, + isMarked: updateRequest.isMarked, + labels: updateRequest.labels, + readAnchor: updateRequest.readAnchor, + readProgress: updateRequest.readProgress, + removeLabels: updateRequest.removeLabels, + title: updateRequest.title + ) + + try await api.updateBookmark(id: id, updateRequest: dto) } } diff --git a/readeck/Domain/DefaultUseCaseFactory.swift b/readeck/Domain/DefaultUseCaseFactory.swift index 19b1783..16d99ec 100644 --- a/readeck/Domain/DefaultUseCaseFactory.swift +++ b/readeck/Domain/DefaultUseCaseFactory.swift @@ -7,6 +7,8 @@ protocol UseCaseFactory { func makeGetBookmarkArticleUseCase() -> GetBookmarkArticleUseCase func makeSaveSettingsUseCase() -> SaveSettingsUseCase func makeLoadSettingsUseCase() -> LoadSettingsUseCase + func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase + func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase } class DefaultUseCaseFactory: UseCaseFactory { @@ -42,9 +44,17 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeLoadSettingsUseCase() -> LoadSettingsUseCase { LoadSettingsUseCase(authRepository: authRepository) } + + func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase { + return UpdateBookmarkUseCase(repository: bookmarksRepository) + } // Nicht mehr nötig - Token wird automatisch geladen func refreshConfiguration() async { // Optional: Cache löschen falls nötig } + + func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase { + return DeleteBookmarkUseCase(repository: bookmarksRepository) + } } diff --git a/readeck/Domain/Model/BookmarkUpdateRequest.swift b/readeck/Domain/Model/BookmarkUpdateRequest.swift new file mode 100644 index 0000000..3c838c8 --- /dev/null +++ b/readeck/Domain/Model/BookmarkUpdateRequest.swift @@ -0,0 +1,62 @@ +import Foundation + +struct BookmarkUpdateRequest { + let addLabels: [String]? + let isArchived: Bool? + let isDeleted: Bool? + let isMarked: Bool? + let labels: [String]? + let readAnchor: String? + let readProgress: Int? + let removeLabels: [String]? + let title: String? + + init( + addLabels: [String]? = nil, + isArchived: Bool? = nil, + isDeleted: Bool? = nil, + isMarked: Bool? = nil, + labels: [String]? = nil, + readAnchor: String? = nil, + readProgress: Int? = nil, + removeLabels: [String]? = nil, + title: String? = nil + ) { + self.addLabels = addLabels + self.isArchived = isArchived + self.isDeleted = isDeleted + self.isMarked = isMarked + self.labels = labels + self.readAnchor = readAnchor + self.readProgress = readProgress + self.removeLabels = removeLabels + self.title = title + } +} + +// Convenience Initializers für häufige Aktionen +extension BookmarkUpdateRequest { + static func archive(_ isArchived: Bool) -> BookmarkUpdateRequest { + return BookmarkUpdateRequest(isArchived: isArchived) + } + + static func favorite(_ isMarked: Bool) -> BookmarkUpdateRequest { + return BookmarkUpdateRequest(isMarked: isMarked) + } + + static func delete(_ isDeleted: Bool) -> BookmarkUpdateRequest { + return BookmarkUpdateRequest(isDeleted: isDeleted) + } + + static func updateProgress(_ progress: Int, anchor: String? = nil) -> BookmarkUpdateRequest { + return BookmarkUpdateRequest(readAnchor: anchor, readProgress: progress) + } + + static func updateTitle(_ title: String) -> BookmarkUpdateRequest { + return BookmarkUpdateRequest(title: title) + } + + static func updateLabels(_ labels: [String]) -> BookmarkUpdateRequest { + return BookmarkUpdateRequest(labels: labels) + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/DeleteBookmarkUseCase.swift b/readeck/Domain/UseCase/DeleteBookmarkUseCase.swift new file mode 100644 index 0000000..cf01232 --- /dev/null +++ b/readeck/Domain/UseCase/DeleteBookmarkUseCase.swift @@ -0,0 +1,13 @@ +import Foundation + +class DeleteBookmarkUseCase { + private let repository: PBookmarksRepository + + init(repository: PBookmarksRepository) { + self.repository = repository + } + + func execute(bookmarkId: String) async throws { + try await repository.deleteBookmark(id: bookmarkId) + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/UpdateBookmarkUseCase.swift b/readeck/Domain/UseCase/UpdateBookmarkUseCase.swift new file mode 100644 index 0000000..fe1e06d --- /dev/null +++ b/readeck/Domain/UseCase/UpdateBookmarkUseCase.swift @@ -0,0 +1,44 @@ +import Foundation + +class UpdateBookmarkUseCase { + private let repository: PBookmarksRepository + + init(repository: PBookmarksRepository) { + self.repository = repository + } + + func execute(bookmarkId: String, updateRequest: BookmarkUpdateRequest) async throws { + try await repository.updateBookmark(id: bookmarkId, updateRequest: updateRequest) + } + + // Convenience methods für häufige Aktionen + func toggleArchive(bookmarkId: String, isArchived: Bool) async throws { + let request = BookmarkUpdateRequest.archive(isArchived) + try await execute(bookmarkId: bookmarkId, updateRequest: request) + } + + func toggleFavorite(bookmarkId: String, isMarked: Bool) async throws { + let request = BookmarkUpdateRequest.favorite(isMarked) + try await execute(bookmarkId: bookmarkId, updateRequest: request) + } + + func markAsDeleted(bookmarkId: String) async throws { + let request = BookmarkUpdateRequest.delete(true) + try await execute(bookmarkId: bookmarkId, updateRequest: request) + } + + func updateReadProgress(bookmarkId: String, progress: Int, anchor: String? = nil) async throws { + let request = BookmarkUpdateRequest.updateProgress(progress, anchor: anchor) + try await execute(bookmarkId: bookmarkId, updateRequest: request) + } + + func updateTitle(bookmarkId: String, title: String) async throws { + let request = BookmarkUpdateRequest.updateTitle(title) + try await execute(bookmarkId: bookmarkId, updateRequest: request) + } + + func updateLabels(bookmarkId: String, labels: [String]) async throws { + let request = BookmarkUpdateRequest.updateLabels(labels) + try await execute(bookmarkId: bookmarkId, updateRequest: request) + } +} \ No newline at end of file diff --git a/readeck/UI/Bookmarks/BookmarkCardView.swift b/readeck/UI/Bookmarks/BookmarkCardView.swift index 2b32300..1661bfa 100644 --- a/readeck/UI/Bookmarks/BookmarkCardView.swift +++ b/readeck/UI/Bookmarks/BookmarkCardView.swift @@ -2,6 +2,12 @@ import SwiftUI struct BookmarkCardView: View { let bookmark: Bookmark + let currentState: BookmarkState + let onArchive: (Bookmark) -> Void + let onDelete: (Bookmark) -> Void + let onToggleFavorite: (Bookmark) -> Void + + @State private var showingActionSheet = false var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -22,18 +28,33 @@ struct BookmarkCardView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 4) { - // Status-Badges + // Status-Badges und Action-Button 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) + HStack(spacing: 4) { + 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() + + // Action Menu Button + Button(action: { + showingActionSheet = true + }) { + Image(systemName: "ellipsis") + .foregroundColor(.secondary) + .padding(8) + .background(Color.gray.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) } // Titel @@ -80,6 +101,36 @@ struct BookmarkCardView: View { .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + .confirmationDialog("Bookmark Aktionen", isPresented: $showingActionSheet) { + actionButtons + } + } + + private var actionButtons: some View { + Group { + // Favorit Toggle + Button(bookmark.isMarked ? "Favorit entfernen" : "Als Favorit markieren") { + onToggleFavorite(bookmark) + } + + // Archivieren/Dearchivieren basierend auf aktuellem State + if currentState == .archived { + Button("Aus Archiv entfernen") { + onArchive(bookmark) + } + } else { + Button("Archivieren") { + onArchive(bookmark) + } + } + + // Permanent löschen (immer verfügbar) + Button("Permanent löschen", role: .destructive) { + onDelete(bookmark) + } + + Button("Abbrechen", role: .cancel) { } + } } private var imageURL: URL? { diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index 1103170..2e3356b 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -7,24 +7,48 @@ struct BookmarksView: View { var body: some View { NavigationView { ZStack { - if viewModel.isLoading { - ProgressView() - } else { - - List(viewModel.bookmarks, id: \.id) { bookmark in - NavigationLink(destination: BookmarkDetailView(bookmarkId: bookmark.id)) { - BookmarkCardView(bookmark: bookmark) + if viewModel.isLoading && viewModel.bookmarks.isEmpty { + ProgressView("Lade \(state.displayName)...") + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.bookmarks, id: \.id) { bookmark in + NavigationLink(destination: BookmarkDetailView(bookmarkId: bookmark.id)) { + BookmarkCardView( + bookmark: bookmark, + currentState: state, + onArchive: { bookmark in + Task { + await viewModel.toggleArchive(bookmark: bookmark) + } + }, + onDelete: { bookmark in + Task { + await viewModel.deleteBookmark(bookmark: bookmark) + } + }, + onToggleFavorite: { bookmark in + Task { + await viewModel.toggleFavorite(bookmark: bookmark) + } + } + ) + .padding(.bottom, 20) + } + .buttonStyle(PlainButtonStyle()) + } } + .padding() } .refreshable { - await viewModel.loadBookmarks() + await viewModel.refreshBookmarks() } .overlay { if viewModel.bookmarks.isEmpty && !viewModel.isLoading { ContentUnavailableView( "Keine Bookmarks", systemImage: "bookmark", - description: Text("Es wurden noch keine Bookmarks gespeichert.") + description: Text("Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden.") ) } } @@ -32,7 +56,9 @@ struct BookmarksView: View { } .navigationTitle(state.displayName) .alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) { - Button("OK", role: .cancel) { } + Button("OK", role: .cancel) { + viewModel.errorMessage = nil + } } message: { Text(viewModel.errorMessage ?? "") } @@ -42,26 +68,3 @@ struct BookmarksView: View { } } } - -// Unterkomponente für die Darstellung eines einzelnen Bookmarks -private struct BookmarkRow: View { - let bookmark: Bookmark - - var body: some View { - 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) - } - } -} diff --git a/readeck/UI/Bookmarks/BookmarksViewModel.swift b/readeck/UI/Bookmarks/BookmarksViewModel.swift index 707898e..a4a28a2 100644 --- a/readeck/UI/Bookmarks/BookmarksViewModel.swift +++ b/readeck/UI/Bookmarks/BookmarksViewModel.swift @@ -3,6 +3,8 @@ import Foundation @Observable class BookmarksViewModel { private let getBooksmarksUseCase = DefaultUseCaseFactory.shared.makeGetBookmarksUseCase() + private let updateBookmarkUseCase = DefaultUseCaseFactory.shared.makeUpdateBookmarkUseCase() + private let deleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase() var bookmarks: [Bookmark] = [] var isLoading = false @@ -34,4 +36,52 @@ class BookmarksViewModel { func refreshBookmarks() async { await loadBookmarks(state: currentState) } + + @MainActor + func toggleArchive(bookmark: Bookmark) async { + do { + try await updateBookmarkUseCase.toggleArchive( + bookmarkId: bookmark.id, + isArchived: !bookmark.isArchived + ) + + // Liste aktualisieren + await loadBookmarks(state: currentState) + + } catch { + errorMessage = "Fehler beim Archivieren des Bookmarks" + } + } + + @MainActor + func toggleFavorite(bookmark: Bookmark) async { + do { + try await updateBookmarkUseCase.toggleFavorite( + bookmarkId: bookmark.id, + isMarked: !bookmark.isMarked + ) + + // Liste aktualisieren + await loadBookmarks(state: currentState) + + } catch { + errorMessage = "Fehler beim Markieren des Bookmarks" + } + } + + @MainActor + func deleteBookmark(bookmark: Bookmark) async { + do { + // Echtes Löschen über API statt nur als gelöscht markieren + try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id) + + // Lokal aus der Liste entfernen (optimistische Update) + bookmarks.removeAll { $0.id == bookmark.id } + + } catch { + errorMessage = "Fehler beim Löschen des Bookmarks" + // Bei Fehler die Liste neu laden, um konsistenten Zustand zu haben + await loadBookmarks(state: currentState) + } + } }