ReadKeep/readeck/UI/BookmarkDetail/BookmarkDetailView.swift
Ilyas Hallak d2e8228903 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
2025-07-08 16:30:27 +02:00

310 lines
12 KiB
Swift

import SwiftUI
import SafariServices
struct BookmarkDetailView: View {
let bookmarkId: String
@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 = 320
var body: some View {
GeometryReader { geometry in
ScrollView {
ZStack(alignment: .top) {
headerView(geometry: geometry)
VStack(alignment: .leading, spacing: 16) {
Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
titleSection
Divider().padding(.horizontal)
contentSection
Spacer(minLength: 40)
archiveSection
}
}
}
.ignoresSafeArea(edges: .top)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 12) {
Button(action: {
showingLabelsSheet = true
}) {
Image(systemName: "tag")
}
Button(action: {
showingFontSettings = true
}) {
Image(systemName: "textformat")
}
}
}
}
.sheet(isPresented: $showingFontSettings) {
NavigationView {
VStack {
FontSettingsView()
.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()
}
.navigationTitle("Schrift-Einstellungen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Fertig") {
showingFontSettings = false
}
}
}
}
}
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.onChange(of: showingFontSettings) { _, isShowing in
if !isShowing {
// Reload settings when sheet is dismissed
Task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
}
}
}
.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)
}
}
// MARK: - ViewBuilder
@ViewBuilder
private func headerView(geometry: GeometryProxy) -> some View {
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
GeometryReader { geo in
let offset = geo.frame(in: .global).minY
ZStack(alignment: .top) {
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
image
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
.clipped()
.offset(y: (offset > 0 ? -offset : 0))
} placeholder: {
Rectangle()
.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(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: 240)
.frame(maxWidth: .infinity)
.offset(y: (offset > 0 ? -offset : 0))
}
}
.frame(height: headerHeight)
.ignoresSafeArea(edges: .top)
}
}
private var titleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text(viewModel.bookmarkDetail.title)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
.padding(.bottom, 2)
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
metaInfoSection
}
.padding(.horizontal)
}
@ViewBuilder
private var contentSection: some View {
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
webViewHeight = height
}
.frame(height: webViewHeight)
.cornerRadius(14)
.padding(.horizontal)
} else if viewModel.isLoadingArticle {
ProgressView("Lade Artikel...")
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
Button(action: {
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
}) {
HStack {
Image(systemName: "safari")
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen")
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal)
.padding(.top, 32)
}
}
private var metaInfoSection: some View {
VStack(alignment: .leading, spacing: 8) {
if !viewModel.bookmarkDetail.authors.isEmpty {
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Autor:innen: " : "Autor: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
}
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)
}) {
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
}
// ViewBuilder für Meta-Infos
@ViewBuilder
private func metaRow(icon: String, text: String) -> some View {
HStack {
Image(systemName: icon)
Text(text)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
@ViewBuilder
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
HStack {
Image(systemName: icon)
content()
}
}
private func formatDate(_ dateString: String) -> String {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoFormatterNoMillis = ISO8601DateFormatter()
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
var date: Date?
if let parsedDate = isoFormatter.date(from: dateString) {
date = parsedDate
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
date = parsedDate
}
if let date = date {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium
displayFormatter.timeStyle = .short
displayFormatter.locale = Locale(identifier: "de_DE")
return displayFormatter.string(from: date)
}
return dateString
}
private var archiveSection: some View {
VStack(spacing: 12) {
Text("Fertig mit Lesen?")
.font(.headline)
.padding(.top, 24)
if viewModel.bookmarkDetail.isArchived {
Label("Bookmark ist archiviert", systemImage: "archivebox.fill")
} else {
Button(action: {
Task {
await viewModel.archiveBookmark(id: bookmarkId)
}
}) {
HStack {
Image(systemName: "archivebox")
Text("Bookmark archivieren")
}
.font(.title3.bold())
.frame(maxWidth: .infinity, maxHeight: 40)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.footnote)
}
}
.padding(.horizontal)
.padding(.bottom, 32)
}
}
#Preview {
NavigationView {
BookmarkDetailView(bookmarkId: "sample-id")
}
}