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)
This commit is contained in:
Ilyas Hallak 2025-11-01 14:03:39 +01:00
parent 7338db5fab
commit 460b05ef34
9 changed files with 102 additions and 1 deletions

View File

@ -95,6 +95,7 @@
UI/Components/UnifiedLabelChip.swift,
UI/Utils/NotificationNames.swift,
Utils/Logger.swift,
Utils/LogStore.swift,
);
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
};

View File

@ -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 {

View File

@ -21,4 +21,8 @@ class AnnotationsRepository: PAnnotationsRepository {
)
}
}
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
}
}

View File

@ -1,3 +1,4 @@
protocol PAnnotationsRepository {
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
}

View File

@ -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)
}
}

View File

@ -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")
}
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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)