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
This commit is contained in:
Ilyas Hallak 2025-06-11 22:31:43 +02:00
parent c8368f0a70
commit cd265730d3
10 changed files with 386 additions and 45 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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