From 460b05ef34100335ac64e112f1eaacacceedc3f4 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 1 Nov 2025 14:03:39 +0100 Subject: [PATCH] Add delete annotation feature with swipe gesture Implemented ability to delete annotations via swipe-to-delete gesture in the annotations list view. Added close button with X icon to dismiss the annotations sheet. Changes: - Added DeleteAnnotationUseCase with repository integration - Extended API with DELETE endpoint for annotations - Implemented swipe-to-delete in AnnotationsListView - Added error handling and optimistic UI updates - Updated toolbar with close button (X icon) --- readeck.xcodeproj/project.pbxproj | 1 + readeck/Data/API/API.swift | 39 +++++++++++++++++++ .../Repository/AnnotationsRepository.swift | 4 ++ .../Protocols/PAnnotationsRepository.swift | 1 + .../UseCase/DeleteAnnotationUseCase.swift | 17 ++++++++ .../BookmarkDetail/AnnotationsListView.swift | 13 ++++++- .../AnnotationsListViewModel.swift | 13 +++++++ .../UI/Factory/DefaultUseCaseFactory.swift | 5 +++ readeck/UI/Factory/MockUseCaseFactory.swift | 10 +++++ 9 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 readeck/Domain/UseCase/DeleteAnnotationUseCase.swift diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 1f7387a..cb8aa43 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ UI/Components/UnifiedLabelChip.swift, UI/Utils/NotificationNames.swift, Utils/Logger.swift, + Utils/LogStore.swift, ); target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */; }; diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index 97cabd9..2e6fa09 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -20,6 +20,7 @@ protocol PAPI { func getBookmarkLabels() async throws -> [BookmarkLabelDto] func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto + func deleteAnnotation(bookmarkId: String, annotationId: String) async throws } class API: PAPI { @@ -486,6 +487,44 @@ class API: PAPI { logger.info("Successfully created annotation for bookmark: \(bookmarkId)") return result } + + func deleteAnnotation(bookmarkId: String, annotationId: String) async throws { + logger.info("Deleting annotation: \(annotationId) from bookmark: \(bookmarkId)") + + let baseURL = await self.baseURL + let fullEndpoint = "/api/bookmarks/\(bookmarkId)/annotations/\(annotationId)" + + guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { + logger.error("Invalid URL: \(baseURL)\(fullEndpoint)") + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if let token = await tokenProvider.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + logger.logNetworkRequest(method: "DELETE", url: url.absoluteString) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid HTTP response for DELETE \(url.absoluteString)") + throw APIError.invalidResponse + } + + guard 200...299 ~= httpResponse.statusCode else { + handleUnauthorizedResponse(httpResponse.statusCode) + logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) + throw APIError.serverError(httpResponse.statusCode) + } + + logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode) + logger.info("Successfully deleted annotation: \(annotationId)") + } } enum HTTPMethod: String { diff --git a/readeck/Data/Repository/AnnotationsRepository.swift b/readeck/Data/Repository/AnnotationsRepository.swift index e4a7afc..724becb 100644 --- a/readeck/Data/Repository/AnnotationsRepository.swift +++ b/readeck/Data/Repository/AnnotationsRepository.swift @@ -21,4 +21,8 @@ class AnnotationsRepository: PAnnotationsRepository { ) } } + + func deleteAnnotation(bookmarkId: String, annotationId: String) async throws { + try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId) + } } diff --git a/readeck/Domain/Protocols/PAnnotationsRepository.swift b/readeck/Domain/Protocols/PAnnotationsRepository.swift index 122b67c..9078c28 100644 --- a/readeck/Domain/Protocols/PAnnotationsRepository.swift +++ b/readeck/Domain/Protocols/PAnnotationsRepository.swift @@ -1,3 +1,4 @@ protocol PAnnotationsRepository { func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] + func deleteAnnotation(bookmarkId: String, annotationId: String) async throws } diff --git a/readeck/Domain/UseCase/DeleteAnnotationUseCase.swift b/readeck/Domain/UseCase/DeleteAnnotationUseCase.swift new file mode 100644 index 0000000..a785b73 --- /dev/null +++ b/readeck/Domain/UseCase/DeleteAnnotationUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol PDeleteAnnotationUseCase { + func execute(bookmarkId: String, annotationId: String) async throws +} + +class DeleteAnnotationUseCase: PDeleteAnnotationUseCase { + private let repository: PAnnotationsRepository + + init(repository: PAnnotationsRepository) { + self.repository = repository + } + + func execute(bookmarkId: String, annotationId: String) async throws { + try await repository.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId) + } +} diff --git a/readeck/UI/BookmarkDetail/AnnotationsListView.swift b/readeck/UI/BookmarkDetail/AnnotationsListView.swift index 4c63668..16b6ef0 100644 --- a/readeck/UI/BookmarkDetail/AnnotationsListView.swift +++ b/readeck/UI/BookmarkDetail/AnnotationsListView.swift @@ -64,6 +64,15 @@ struct AnnotationsListView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + Task { + await viewModel.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotation.id) + } + } label: { + Label("Delete", systemImage: "trash") + } + } } case .error: @@ -74,8 +83,10 @@ struct AnnotationsListView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { + Button { dismiss() + } label: { + Image(systemName: "xmark") } } } diff --git a/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift b/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift index aede002..9f120a9 100644 --- a/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift +++ b/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift @@ -3,6 +3,7 @@ import Foundation @Observable class AnnotationsListViewModel { private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase + private let deleteAnnotationUseCase: PDeleteAnnotationUseCase var annotations: [Annotation] = [] var isLoading = false @@ -11,6 +12,7 @@ class AnnotationsListViewModel { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase() + self.deleteAnnotationUseCase = factory.makeDeleteAnnotationUseCase() } @MainActor @@ -26,4 +28,15 @@ class AnnotationsListViewModel { showErrorAlert = true } } + + @MainActor + func deleteAnnotation(bookmarkId: String, annotationId: String) async { + do { + try await deleteAnnotationUseCase.execute(bookmarkId: bookmarkId, annotationId: annotationId) + annotations.removeAll { $0.id == annotationId } + } catch { + errorMessage = "Failed to delete annotation" + showErrorAlert = true + } + } } diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index 1a69ce2..34798d7 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -22,6 +22,7 @@ protocol UseCaseFactory { func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase + func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase } @@ -125,4 +126,8 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase { return GetBookmarkAnnotationsUseCase(repository: annotationsRepository) } + + func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase { + return DeleteAnnotationUseCase(repository: annotationsRepository) + } } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index 6bf191c..fb83faf 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -92,6 +92,10 @@ class MockUseCaseFactory: UseCaseFactory { func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase { MockGetBookmarkAnnotationsUseCase() } + + func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase { + MockDeleteAnnotationUseCase() + } } @@ -250,6 +254,12 @@ class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase { } } +class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase { + func execute(bookmarkId: String, annotationId: String) async throws { + // Mock implementation - do nothing + } +} + extension Bookmark { static let mock: Bookmark = .init( id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)