feat: Add label management to bookmarks and UI improvements

- BookmarkDetail: Add labels property, display and manage labels in detail view
- Add AddLabelsToBookmarkUseCase and RemoveLabelsFromBookmarkUseCase
- Update UpdateBookmarkUseCase and BookmarkUpdateRequest for label operations
- UI: Show labels in BookmarkDetailView, add label management sheet
- DefaultUseCaseFactory: Provide use cases for label management
- Localizable: Add/adjust label-related strings, minor cleanup
- SettingsServerView: Update debug endpoint
- SidebarTab: Change 'Alle' to 'All'
- Project: Remove unused region from Xcode project
This commit is contained in:
Ilyas Hallak 2025-07-08 16:30:27 +02:00
parent 2c5b51ca3a
commit d2e8228903
15 changed files with 471 additions and 40 deletions

View File

@ -14,20 +14,27 @@
}, },
"Abbrechen" : { "Abbrechen" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Abbrechen"
}
}
}
}, },
"Abmelden" : { "Abmelden" : {
}, },
"Add Item" : { "Add Item" : {
},
"Aktuelle Labels" : {
},
"all" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ale"
}
}
}
}, },
"Anmelden & speichern" : { "Anmelden & speichern" : {
@ -61,17 +68,6 @@
}, },
"Debug-Anmeldung" : { "Debug-Anmeldung" : {
},
"done" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fertig"
}
}
}
}, },
"Einfügen" : { "Einfügen" : {
@ -117,17 +113,6 @@
}, },
"Fertig mit Lesen?" : { "Fertig mit Lesen?" : {
},
"font_settings_title" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schrift-Einstellungen"
}
}
}
}, },
"Füge einen neuen Link zu deiner Sammlung hinzu" : { "Füge einen neuen Link zu deiner Sammlung hinzu" : {
@ -161,9 +146,21 @@
}, },
"Keine Ergebnisse" : { "Keine Ergebnisse" : {
},
"Keine Labels vorhanden" : {
},
"Key" : {
"extractionState" : "manual"
},
"Label eingeben..." : {
}, },
"Labels" : { "Labels" : {
},
"Labels verwalten" : {
}, },
"Lade %@..." : { "Lade %@..." : {
@ -185,6 +182,9 @@
}, },
"Neues Bookmark" : { "Neues Bookmark" : {
},
"Neues Label hinzufügen" : {
}, },
"OK" : { "OK" : {

View File

@ -319,7 +319,6 @@
knownRegions = ( knownRegions = (
en, en,
Base, Base,
"fr-CA",
de, de,
); );
mainGroup = 5D45F9BF2DF858680048D5B8; mainGroup = 5D45F9BF2DF858680048D5B8;

View File

@ -38,6 +38,7 @@ class BookmarksRepository: PBookmarksRepository {
hasArticle: bookmarkDetailDto.hasArticle, hasArticle: bookmarkDetailDto.hasArticle,
isMarked: bookmarkDetailDto.isMarked, isMarked: bookmarkDetailDto.isMarked,
isArchived: bookmarkDetailDto.isArchived, isArchived: bookmarkDetailDto.isArchived,
labels: bookmarkDetailDto.labels,
thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "", thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "",
imageUrl: bookmarkDetailDto.resources.image?.src ?? "" imageUrl: bookmarkDetailDto.resources.image?.src ?? ""
) )

View File

@ -14,6 +14,7 @@ struct BookmarkDetail {
let hasArticle: Bool let hasArticle: Bool
let isMarked: Bool let isMarked: Bool
var isArchived: Bool var isArchived: Bool
let labels: [String]
let thumbnailUrl: String let thumbnailUrl: String
let imageUrl: String let imageUrl: String
} }
@ -33,6 +34,7 @@ extension BookmarkDetail {
hasArticle: false, hasArticle: false,
isMarked: false, isMarked: false,
isArchived: false, isArchived: false,
labels: [],
thumbnailUrl: "", thumbnailUrl: "",
imageUrl: "" imageUrl: ""
) )

View File

@ -59,4 +59,12 @@ extension BookmarkUpdateRequest {
static func updateLabels(_ labels: [String]) -> BookmarkUpdateRequest { static func updateLabels(_ labels: [String]) -> BookmarkUpdateRequest {
return BookmarkUpdateRequest(labels: labels) return BookmarkUpdateRequest(labels: labels)
} }
static func addLabels(_ labels: [String]) -> BookmarkUpdateRequest {
return BookmarkUpdateRequest(addLabels: labels)
}
static func removeLabels(_ labels: [String]) -> BookmarkUpdateRequest {
return BookmarkUpdateRequest(removeLabels: labels)
}
} }

View File

@ -0,0 +1,43 @@
import Foundation
class AddLabelsToBookmarkUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {
self.repository = repository
}
func execute(bookmarkId: String, labels: [String]) async throws {
// Validierung der Labels
guard !labels.isEmpty else {
throw BookmarkUpdateError.emptyLabels
}
// Entferne leere Labels und Duplikate
let cleanLabels = Array(Set(labels.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
guard !cleanLabels.isEmpty else {
throw BookmarkUpdateError.emptyLabels
}
let request = BookmarkUpdateRequest.addLabels(cleanLabels)
try await repository.updateBookmark(id: bookmarkId, updateRequest: request)
}
// Convenience method für einzelne Labels
func execute(bookmarkId: String, label: String) async throws {
try await execute(bookmarkId: bookmarkId, labels: [label])
}
}
// Custom error für Label-Operationen
enum BookmarkUpdateError: LocalizedError {
case emptyLabels
var errorDescription: String? {
switch self {
case .emptyLabels:
return "Labels können nicht leer sein"
}
}
}

View File

@ -0,0 +1,31 @@
import Foundation
class RemoveLabelsFromBookmarkUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {
self.repository = repository
}
func execute(bookmarkId: String, labels: [String]) async throws {
// Validierung der Labels
guard !labels.isEmpty else {
throw BookmarkUpdateError.emptyLabels
}
// Entferne leere Labels und Duplikate
let cleanLabels = Array(Set(labels.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
guard !cleanLabels.isEmpty else {
throw BookmarkUpdateError.emptyLabels
}
let request = BookmarkUpdateRequest.removeLabels(cleanLabels)
try await repository.updateBookmark(id: bookmarkId, updateRequest: request)
}
// Convenience method für einzelne Labels
func execute(bookmarkId: String, label: String) async throws {
try await execute(bookmarkId: bookmarkId, labels: [label])
}
}

View File

@ -41,4 +41,14 @@ class UpdateBookmarkUseCase {
let request = BookmarkUpdateRequest.updateLabels(labels) let request = BookmarkUpdateRequest.updateLabels(labels)
try await execute(bookmarkId: bookmarkId, updateRequest: request) try await execute(bookmarkId: bookmarkId, updateRequest: request)
} }
func addLabels(bookmarkId: String, labels: [String]) async throws {
let request = BookmarkUpdateRequest.addLabels(labels)
try await execute(bookmarkId: bookmarkId, updateRequest: request)
}
func removeLabels(bookmarkId: String, labels: [String]) async throws {
let request = BookmarkUpdateRequest.removeLabels(labels)
try await execute(bookmarkId: bookmarkId, updateRequest: request)
}
} }

View File

@ -6,8 +6,9 @@ struct BookmarkDetailView: View {
@State private var viewModel = BookmarkDetailViewModel() @State private var viewModel = BookmarkDetailViewModel()
@State private var webViewHeight: CGFloat = 300 @State private var webViewHeight: CGFloat = 300
@State private var showingFontSettings = false @State private var showingFontSettings = false
@State private var showingLabelsSheet = false
private let headerHeight: CGFloat = 260 private let headerHeight: CGFloat = 320
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@ -29,6 +30,13 @@ struct BookmarkDetailView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 12) {
Button(action: {
showingLabelsSheet = true
}) {
Image(systemName: "tag")
}
Button(action: { Button(action: {
showingFontSettings = true showingFontSettings = true
}) { }) {
@ -36,6 +44,7 @@ struct BookmarkDetailView: View {
} }
} }
} }
}
.sheet(isPresented: $showingFontSettings) { .sheet(isPresented: $showingFontSettings) {
NavigationView { NavigationView {
VStack { VStack {
@ -57,6 +66,9 @@ struct BookmarkDetailView: View {
} }
} }
} }
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.onChange(of: showingFontSettings) { _, isShowing in .onChange(of: showingFontSettings) { _, isShowing in
if !isShowing { if !isShowing {
// Reload settings when sheet is dismissed // Reload settings when sheet is dismissed
@ -65,6 +77,14 @@ struct BookmarkDetailView: View {
} }
} }
} }
.onChange(of: showingLabelsSheet) { _, isShowing in
if !isShowing {
// Reload bookmark detail when labels sheet is dismissed
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.task { .task {
await viewModel.loadBookmarkDetail(id: bookmarkId) await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId) await viewModel.loadArticleContent(id: bookmarkId)
@ -91,13 +111,22 @@ struct BookmarkDetailView: View {
.fill(Color.gray.opacity(0.4)) .fill(Color.gray.opacity(0.4))
.frame(width: geometry.size.width, height: headerHeight) .frame(width: geometry.size.width, height: headerHeight)
} }
// Gradient overlay für bessere Button-Sichtbarkeit
LinearGradient( LinearGradient(
gradient: Gradient(colors: [Color.black.opacity(0.6), Color.clear]), gradient: Gradient(colors: [
Color.black.opacity(1.0),
Color.black.opacity(0.9),
Color.black.opacity(0.7),
Color.black.opacity(0.4),
Color.black.opacity(0.2),
Color.clear
]),
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
) )
.frame(height: 120) .frame(height: 240)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.offset(y: (offset > 0 ? -offset : 0))
} }
} }
.frame(height: headerHeight) .frame(height: headerHeight)
@ -155,6 +184,38 @@ struct BookmarkDetailView: View {
} }
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created)) metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter • \(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit") metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter • \(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit")
// Labels section
if !viewModel.bookmarkDetail.labels.isEmpty {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "tag")
.foregroundColor(.secondary)
.padding(.top, 2)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.accentColor.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
)
)
}
}
.padding(.trailing, 8)
}
}
}
metaRow(icon: "safari") { metaRow(icon: "safari") {
Button(action: { Button(action: {
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url) SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)

View File

@ -73,4 +73,9 @@ class BookmarkDetailViewModel {
} }
isLoading = false isLoading = false
} }
@MainActor
func refreshBookmarkDetail(id: String) async {
await loadBookmarkDetail(id: id)
}
} }

View File

@ -0,0 +1,175 @@
import SwiftUI
struct BookmarkLabelsView: View {
let bookmarkId: String
@State private var viewModel: BookmarkLabelsViewModel
@Environment(\.dismiss) private var dismiss
init(bookmarkId: String, initialLabels: [String]) {
self.bookmarkId = bookmarkId
self._viewModel = State(initialValue: BookmarkLabelsViewModel(initialLabels: initialLabels))
}
var body: some View {
NavigationView {
VStack(spacing: 16) {
// Add new label section
addLabelSection
Divider()
.padding(.horizontal, -16)
// Current labels section
currentLabelsSection
Spacer()
}
.padding()
.background(Color(.systemGroupedBackground))
.navigationTitle("Labels verwalten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Abbrechen") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Fertig") {
dismiss()
}
}
}
.alert("Fehler", isPresented: $viewModel.showErrorAlert) {
Button("OK") { }
} message: {
Text(viewModel.errorMessage ?? "Unbekannter Fehler")
}
}
}
private var addLabelSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Neues Label hinzufügen")
.font(.headline)
.foregroundColor(.primary)
HStack(spacing: 12) {
TextField("Label eingeben...", text: $viewModel.newLabelText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onSubmit {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
}
}
Button(action: {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
}
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(
Circle()
.fill(Color.accentColor)
)
}
.disabled(viewModel.newLabelText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
)
}
private var currentLabelsSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Aktuelle Labels")
.font(.headline)
.foregroundColor(.primary)
Spacer()
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
}
}
if viewModel.currentLabels.isEmpty {
VStack(spacing: 8) {
Image(systemName: "tag")
.font(.title2)
.foregroundColor(.secondary)
Text("Keine Labels vorhanden")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
} else {
LazyVGrid(columns: [
GridItem(.adaptive(minimum: 80, maximum: 150))
], spacing: 4) {
ForEach(viewModel.currentLabels, id: \.self) { label in
LabelChip(
label: label,
onRemove: {
Task {
await viewModel.removeLabel(from: bookmarkId, label: label)
}
}
)
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
)
}
}
struct LabelChip: View {
let label: String
let onRemove: () -> Void
var body: some View {
HStack(spacing: 6) {
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.accentColor.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.accentColor.opacity(0.4), lineWidth: 1)
)
)
}
}
#Preview {
BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"])
}

View File

@ -0,0 +1,86 @@
import Foundation
@Observable
class BookmarkLabelsViewModel {
private let addLabelsUseCase = DefaultUseCaseFactory.shared.makeAddLabelsToBookmarkUseCase()
private let removeLabelsUseCase = DefaultUseCaseFactory.shared.makeRemoveLabelsFromBookmarkUseCase()
var isLoading = false
var errorMessage: String?
var showErrorAlert = false
var currentLabels: [String] = []
var newLabelText = ""
init(initialLabels: [String] = []) {
self.currentLabels = initialLabels
}
@MainActor
func addLabels(to bookmarkId: String, labels: [String]) async {
isLoading = true
errorMessage = nil
do {
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
// Update local labels
currentLabels.append(contentsOf: labels)
currentLabels = Array(Set(currentLabels)) // Remove duplicates
} catch let error as BookmarkUpdateError {
errorMessage = error.localizedDescription
showErrorAlert = true
} catch {
errorMessage = "Fehler beim Hinzufügen der Labels"
showErrorAlert = true
}
isLoading = false
}
@MainActor
func addLabel(to bookmarkId: String, label: String) async {
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedLabel.isEmpty else { return }
await addLabels(to: bookmarkId, labels: [trimmedLabel])
newLabelText = ""
}
@MainActor
func removeLabels(from bookmarkId: String, labels: [String]) async {
isLoading = true
errorMessage = nil
do {
try await removeLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
// Update local labels
currentLabels.removeAll { labels.contains($0) }
} catch let error as BookmarkUpdateError {
errorMessage = error.localizedDescription
showErrorAlert = true
} catch {
errorMessage = "Fehler beim Entfernen der Labels"
showErrorAlert = true
}
isLoading = false
}
@MainActor
func removeLabel(from bookmarkId: String, label: String) async {
await removeLabels(from: bookmarkId, labels: [label])
}
// Convenience method für das Umschalten eines Labels (hinzufügen wenn nicht vorhanden, entfernen wenn vorhanden)
@MainActor
func toggleLabel(for bookmarkId: String, label: String) async {
if currentLabels.contains(label) {
await removeLabel(from: bookmarkId, label: label)
} else {
await addLabel(to: bookmarkId, label: label)
}
}
func updateLabels(_ labels: [String]) {
currentLabels = labels
}
}

View File

@ -13,6 +13,8 @@ protocol UseCaseFactory {
func makeLogoutUseCase() -> LogoutUseCase func makeLogoutUseCase() -> LogoutUseCase
func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase
} }
class DefaultUseCaseFactory: UseCaseFactory { class DefaultUseCaseFactory: UseCaseFactory {
@ -78,4 +80,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase { func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase {
return SaveServerSettingsUseCase(repository: SettingsRepository()) return SaveServerSettingsUseCase(repository: SettingsRepository())
} }
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase {
return AddLabelsToBookmarkUseCase(repository: bookmarksRepository)
}
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase {
return RemoveLabelsFromBookmarkUseCase(repository: bookmarksRepository)
}
} }

View File

@ -12,7 +12,7 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
var label: String { var label: String {
switch self { switch self {
case .all: return "Alle" case .all: return "All"
case .unread: return "Ungelesen" case .unread: return "Ungelesen"
case .favorite: return "Favoriten" case .favorite: return "Favoriten"
case .archived: return "Archiv" case .archived: return "Archiv"

View File

@ -126,7 +126,7 @@ struct SettingsServerView: View {
Button("Debug-Anmeldung") { Button("Debug-Anmeldung") {
viewModel.username = "admin" viewModel.username = "admin"
viewModel.password = "Diggah123" viewModel.password = "Diggah123"
viewModel.endpoint = "https://readeck.mnk.any64.de" viewModel.endpoint = "https://keep.mnk.any64.de"
} }
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)