import SwiftUI import SafariServices import Combine // PreferenceKey for logging scroll offset struct ScrollOffsetPreferenceKey: PreferenceKey { typealias Value = CGFloat static var defaultValue = CGFloat.zero static func reduce(value: inout Value, nextValue: () -> Value) { value += nextValue() } } 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 @State private var showDismissButton = false @State private var readingProgress: Double = 0.0 // contentHeight entfernt, webViewHeight wird verwendet @State private var scrollViewHeight: CGFloat = 1 @EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var appSettings: AppSettings @Environment(\.dismiss) private var dismiss private let headerHeight: CGFloat = 320 init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) { self.bookmarkId = bookmarkId self.viewModel = viewModel self.webViewHeight = webViewHeight self.showingFontSettings = showingFontSettings self.showingLabelsSheet = showingLabelsSheet } var body: some View { VStack(spacing: 0) { ProgressView(value: readingProgress) .progressViewStyle(LinearProgressViewStyle()) .frame(height: 3) GeometryReader { outerGeo in ScrollView { VStack(spacing: 0) { // Track scroll offset at the top GeometryReader { geo in Color.clear .preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) } .frame(height: 0) ZStack(alignment: .top) { headerView(geometry: outerGeo) VStack(alignment: .center, spacing: 16) { Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight) titleSection Divider().padding(.horizontal) if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in webViewHeight = height }) .frame(height: webViewHeight) .cornerRadius(14) .padding(.horizontal) .animation(.easeInOut, value: webViewHeight) } else if viewModel.isLoadingArticle { ProgressView("Loading article...") .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) ?? "Open original page") + " open") } .font(.title3.bold()) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .padding(.horizontal) .padding(.top, 0) } Spacer(minLength: 40) if viewModel.isLoadingArticle == false { archiveSection .transition(.opacity.combined(with: .move(edge: .bottom))) .animation(.easeInOut, value: viewModel.articleContent) } } } // Kein GeometryReader am Ende nötig } } .coordinateSpace(name: "scroll") .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in scrollViewHeight = outerGeo.size.height print("offset:", offset) print("webViewHeight:", webViewHeight) print("scrollViewHeight:", scrollViewHeight) let maxOffset = webViewHeight - scrollViewHeight print("maxOffset:", maxOffset) // Am Anfang: offset = 0, am Ende: offset = -maxOffset let rawProgress = -offset / (maxOffset != 0 ? maxOffset : 1) print("rawProgress:", rawProgress) let progress = min(max(rawProgress, 0), 1) print("progress:", progress) readingProgress = progress } .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("Font Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { 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 withAnimation(.easeInOut(duration: 0.1)) { webViewHeight = height } } .frame(height: webViewHeight) .cornerRadius(14) .padding(.horizontal) .animation(.easeInOut, value: webViewHeight) } else if viewModel.isLoadingArticle { ProgressView("Loading article...") .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) ?? "Open original page") + " open") } .font(.title3.bold()) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .padding(.horizontal) .padding(.top, 0) } } private var metaInfoSection: some View { VStack(alignment: .leading, spacing: 8) { if !viewModel.bookmarkDetail.authors.isEmpty { metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", ")) } metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created)) metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read") // 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) ?? "Open original page") + " open") .font(.subheadline) .foregroundColor(.secondary) } } if appSettings.enableTTS { metaRow(icon: "speaker.wave.2") { Button(action: { viewModel.addBookmarkToSpeechQueue() playerUIState.showPlayer() }) { Text("Read article aloud") .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(alignment: .center, spacing: 12) { Text("Finished reading?") .font(.headline) .padding(.top, 24) VStack(alignment: .center, spacing: 16) { Button(action: { Task { await viewModel.toggleFavorite(id: bookmarkId) } }) { HStack { Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star") .foregroundColor(viewModel.bookmarkDetail.isMarked ? .yellow : .gray) Text(viewModel.bookmarkDetail.isMarked ? "Favorite" : "Mark as favorite") } .font(.title3.bold()) .frame(maxHeight: 60) .padding(10) } .buttonStyle(.bordered) .disabled(viewModel.isLoading) // Archive button Button(action: { Task { await viewModel.archiveBookmark(id: bookmarkId) if viewModel.bookmarkDetail.isArchived { showDismissButton = true } } }) { HStack { Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle.fill" : "archivebox") .foregroundColor(viewModel.bookmarkDetail.isArchived ? .green : .primary) Text(viewModel.bookmarkDetail.isArchived ? "Archived" : "Archive bookmark") } .font(.title3.bold()) .frame(maxHeight: 60) .padding(10) } .buttonStyle(.borderedProminent) .disabled(viewModel.isLoading || viewModel.bookmarkDetail.isArchived) } if showDismissButton { Button(action: { dismiss() }) { Label("Go Back", systemImage: "arrow.backward.circle") .font(.title3.bold()) .padding(.top, 8) } .id("goBackButton") } if let error = viewModel.errorMessage { Text(error) .foregroundColor(.red) .font(.footnote) } } .padding(.horizontal) .padding(.bottom, 32) } } #Preview { NavigationView { BookmarkDetailView(bookmarkId: "123", viewModel: .init(MockUseCaseFactory()), webViewHeight: 300, showingFontSettings: false, showingLabelsSheet: false, playerUIState: .init()) } }