- Move and replace utility files (SafariUtil, SpeechQueue, StringExtensions, TTSManager, VoiceManager) - Refactor and extend SpeechPlayer components (UI, progress, volume, queue) - Improved state and EnvironmentObject management (PlayerUIState) - UI and logic optimizations in menu and tab views - Remove obsolete and duplicate files - General code and UX improvements
321 lines
12 KiB
Swift
321 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
|
|
@EnvironmentObject var playerUIState: PlayerUIState
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
metaRow(icon: "speaker.wave.2") {
|
|
Button(action: {
|
|
viewModel.addBookmarkToSpeechQueue()
|
|
playerUIState.showPlayer()
|
|
}) {
|
|
Text("Artikel vorlesen")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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")
|
|
}
|
|
}
|