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" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Abbrechen"
}
}
}
},
"Abmelden" : {
},
"Add Item" : {
},
"Aktuelle Labels" : {
},
"all" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ale"
}
}
}
},
"Anmelden & speichern" : {
@ -61,17 +68,6 @@
},
"Debug-Anmeldung" : {
},
"done" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fertig"
}
}
}
},
"Einfügen" : {
@ -117,17 +113,6 @@
},
"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" : {
@ -161,9 +146,21 @@
},
"Keine Ergebnisse" : {
},
"Keine Labels vorhanden" : {
},
"Key" : {
"extractionState" : "manual"
},
"Label eingeben..." : {
},
"Labels" : {
},
"Labels verwalten" : {
},
"Lade %@..." : {
@ -185,6 +182,9 @@
},
"Neues Bookmark" : {
},
"Neues Label hinzufügen" : {
},
"OK" : {

View File

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

View File

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

View File

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

View File

@ -59,4 +59,12 @@ extension BookmarkUpdateRequest {
static func updateLabels(_ labels: [String]) -> BookmarkUpdateRequest {
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)
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 webViewHeight: CGFloat = 300
@State private var showingFontSettings = false
@State private var showingLabelsSheet = false
private let headerHeight: CGFloat = 260
private let headerHeight: CGFloat = 320
var body: some View {
GeometryReader { geometry in
@ -29,10 +30,18 @@ struct BookmarkDetailView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingFontSettings = true
}) {
Image(systemName: "textformat")
HStack(spacing: 12) {
Button(action: {
showingLabelsSheet = true
}) {
Image(systemName: "tag")
}
Button(action: {
showingFontSettings = true
}) {
Image(systemName: "textformat")
}
}
}
}
@ -57,6 +66,9 @@ struct BookmarkDetailView: View {
}
}
}
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.onChange(of: showingFontSettings) { _, isShowing in
if !isShowing {
// 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 {
await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId)
@ -91,13 +111,22 @@ struct BookmarkDetailView: View {
.fill(Color.gray.opacity(0.4))
.frame(width: geometry.size.width, height: headerHeight)
}
// Gradient overlay für bessere Button-Sichtbarkeit
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,
endPoint: .bottom
)
.frame(height: 120)
.frame(height: 240)
.frame(maxWidth: .infinity)
.offset(y: (offset > 0 ? -offset : 0))
}
}
.frame(height: headerHeight)
@ -155,6 +184,38 @@ struct BookmarkDetailView: View {
}
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
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") {
Button(action: {
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)

View File

@ -73,4 +73,9 @@ class BookmarkDetailViewModel {
}
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 makeSearchBookmarksUseCase() -> SearchBookmarksUseCase
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase
}
class DefaultUseCaseFactory: UseCaseFactory {
@ -78,4 +80,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase {
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 {
switch self {
case .all: return "Alle"
case .all: return "All"
case .unread: return "Ungelesen"
case .favorite: return "Favoriten"
case .archived: return "Archiv"

View File

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