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:
parent
7338db5fab
commit
460b05ef34
@ -95,6 +95,7 @@
|
|||||||
UI/Components/UnifiedLabelChip.swift,
|
UI/Components/UnifiedLabelChip.swift,
|
||||||
UI/Utils/NotificationNames.swift,
|
UI/Utils/NotificationNames.swift,
|
||||||
Utils/Logger.swift,
|
Utils/Logger.swift,
|
||||||
|
Utils/LogStore.swift,
|
||||||
);
|
);
|
||||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -20,6 +20,7 @@ protocol PAPI {
|
|||||||
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
|
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
|
||||||
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
|
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 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 {
|
class API: PAPI {
|
||||||
@ -486,6 +487,44 @@ class API: PAPI {
|
|||||||
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
|
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
|
||||||
return result
|
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 {
|
enum HTTPMethod: String {
|
||||||
|
|||||||
@ -21,4 +21,8 @@ class AnnotationsRepository: PAnnotationsRepository {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
|
||||||
|
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
protocol PAnnotationsRepository {
|
protocol PAnnotationsRepository {
|
||||||
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
||||||
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
||||||
}
|
}
|
||||||
|
|||||||
17
readeck/Domain/UseCase/DeleteAnnotationUseCase.swift
Normal file
17
readeck/Domain/UseCase/DeleteAnnotationUseCase.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -64,6 +64,15 @@ struct AnnotationsListView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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:
|
case .error:
|
||||||
@ -74,8 +83,10 @@ struct AnnotationsListView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Done") {
|
Button {
|
||||||
dismiss()
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
class AnnotationsListViewModel {
|
class AnnotationsListViewModel {
|
||||||
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
|
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
|
||||||
|
private let deleteAnnotationUseCase: PDeleteAnnotationUseCase
|
||||||
|
|
||||||
var annotations: [Annotation] = []
|
var annotations: [Annotation] = []
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
@ -11,6 +12,7 @@ class AnnotationsListViewModel {
|
|||||||
|
|
||||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
|
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
|
||||||
|
self.deleteAnnotationUseCase = factory.makeDeleteAnnotationUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -26,4 +28,15 @@ class AnnotationsListViewModel {
|
|||||||
showErrorAlert = true
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ protocol UseCaseFactory {
|
|||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||||
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
||||||
|
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -125,4 +126,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||||
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
|
||||||
|
return DeleteAnnotationUseCase(repository: annotationsRepository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,6 +92,10 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||||
MockGetBookmarkAnnotationsUseCase()
|
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 {
|
extension Bookmark {
|
||||||
static let mock: Bookmark = .init(
|
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)
|
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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user