diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift new file mode 100644 index 0000000..bd156f8 --- /dev/null +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -0,0 +1,478 @@ +import SwiftUI +import SafariServices +import Combine + +struct BookmarkDetailLegacyView: View { + let bookmarkId: String + @Binding var useNativeWebView: Bool + + // MARK: - States + + @State private var viewModel: BookmarkDetailViewModel + @State private var webViewHeight: CGFloat = 300 + @State private var showingFontSettings = false + @State private var showingLabelsSheet = false + @State private var readingProgress: Double = 0.0 + @State private var scrollViewHeight: CGFloat = 1 + @State private var currentScrollOffset: CGFloat = 0 + @State private var showJumpToProgressButton: Bool = false + @State private var scrollPosition = ScrollPosition(edge: .top) + @State private var showingImageViewer = false + + // MARK: - Envs + + @EnvironmentObject var playerUIState: PlayerUIState + @EnvironmentObject var appSettings: AppSettings + @Environment(\.dismiss) private var dismiss + + private let headerHeight: CGFloat = 360 + + init(bookmarkId: String, useNativeWebView: Binding, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) { + self.bookmarkId = bookmarkId + self._useNativeWebView = useNativeWebView + self.viewModel = viewModel + } + + var body: some View { + VStack(spacing: 0) { + ProgressView(value: readingProgress) + .progressViewStyle(LinearProgressViewStyle()) + .frame(height: 3) + GeometryReader { geometry in + ScrollView { + VStack(spacing: 0) { + ZStack(alignment: .top) { + headerView(width: geometry.size.width) + VStack(alignment: .leading, spacing: 16) { + Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight) + titleSection + Divider().padding(.horizontal) + if showJumpToProgressButton { + JumpButton() + } + if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { + WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in + if webViewHeight != height { + webViewHeight = height + } + }) + .frame(height: webViewHeight) + .cornerRadius(14) + .padding(.horizontal, 4) + } else if viewModel.isLoadingArticle { + ProgressView("Loading article...") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else { + Button(action: { + URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) + }) { + HStack { + Image(systemName: "safari") + Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page")) + } + .font(.title3.bold()) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal) + .padding(.top, 0) + } + + if viewModel.isLoadingArticle == false && viewModel.isLoading == false { + VStack(alignment: .center) { + archiveSection + .transition(.opacity.combined(with: .move(edge: .bottom))) + .animation(.easeInOut, value: viewModel.articleContent) + } + .frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity) + } + } + } + .ignoresSafeArea(edges: .top) + .scrollPosition($scrollPosition) + .onScrollGeometryChange(for: CGFloat.self) { geo in + geo.contentOffset.y + } action: { oldValue, newValue in + // Just track current offset, don't calculate yet + currentScrollOffset = newValue + } + .onScrollGeometryChange(for: CGFloat.self) { geo in + geo.containerSize.height + } action: { oldValue, newValue in + scrollViewHeight = newValue + } + .onScrollPhaseChange { oldPhase, newPhase in + // Only calculate progress when scrolling ends + if oldPhase == .interacting && newPhase == .idle { + let offset = currentScrollOffset + let maxOffset = webViewHeight - geometry.size.height + let rawProgress = offset / (maxOffset > 0 ? maxOffset : 1) + let progress = min(max(rawProgress, 0), 1) + + // Only update if change is significant (> 5%) + let threshold: Double = 0.05 + if abs(progress - readingProgress) > threshold { + readingProgress = progress + viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil) + } + } + } + } + } + .frame(maxWidth: .infinity) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + // Toggle button (left) + ToolbarItem(placement: .navigationBarLeading) { + if #available(iOS 26.0, *) { + Button(action: { + useNativeWebView.toggle() + }) { + Image(systemName: "waveform") + .foregroundColor(.accentColor) + } + } + } + + // Top toolbar (right) + 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) + } + .sheet(isPresented: $showingImageViewer) { + ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) + } + .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) + } + } + } + .onChange(of: viewModel.readProgress) { _, progress in + showJumpToProgressButton = progress > 0 && progress < 100 + } + .task { + await viewModel.loadBookmarkDetail(id: bookmarkId) + await viewModel.loadArticleContent(id: bookmarkId) + } + } + + // MARK: - ViewBuilder + + @ViewBuilder + private func headerView(width: CGFloat) -> some View { + if !viewModel.bookmarkDetail.imageUrl.isEmpty { + ZStack(alignment: .bottomTrailing) { + CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) + .aspectRatio(contentMode: .fill) + .frame(width: width, height: headerHeight) + .clipped() + + // Zoom icon + Button(action: { + showingImageViewer = true + }) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .padding(8) + .background( + Circle() + .fill(Color.black.opacity(0.6)) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + ) + } + .padding(.trailing, 16) + .padding(.bottom, 16) + } + .frame(height: headerHeight) + .ignoresSafeArea(edges: .top) + .onTapGesture { + showingImageViewer = true + } + } + } + + 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(maxWidth: .infinity) + .frame(height: webViewHeight) + .cornerRadius(14) + .padding(.horizontal, 4) + .animation(.easeInOut, value: webViewHeight) + } else if viewModel.isLoadingArticle { + ProgressView("Loading article...") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else { + Button(action: { + URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) + }) { + 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: { + URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) + }) { + 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 = .autoupdatingCurrent + 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, isArchive: !viewModel.bookmarkDetail.isArchived) + } + }) { + HStack { + Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox") + Text(viewModel.bookmarkDetail.isArchived ? "Unarchive Bookmark" : "Archive bookmark") + } + .font(.title3.bold()) + .frame(maxHeight: 60) + .padding(10) + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isLoading) + } + if let error = viewModel.errorMessage { + Text(error) + .foregroundColor(.red) + .font(.footnote) + } + } + .padding(.horizontal) + .padding(.bottom, 32) + } + + @ViewBuilder + func JumpButton() -> some View { + Button(action: { + let maxOffset = webViewHeight - scrollViewHeight + let offset = maxOffset * (Double(viewModel.readProgress) / 100.0) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + scrollPosition = ScrollPosition(y: offset) + showJumpToProgressButton = false + } + }) { + Text("Jump to last read position (\(viewModel.readProgress)%)") + .font(.subheadline) + .padding(8) + .frame(maxWidth: .infinity) + } + .background(Color.accentColor.opacity(0.15)) + .cornerRadius(8) + .padding([.top, .horizontal]) + } +} + +#Preview { + NavigationView { + BookmarkDetailLegacyView( + bookmarkId: "123", + useNativeWebView: .constant(false), + viewModel: .init(MockUseCaseFactory()) + ) + } +} diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift index 10f4f90..4506502 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -1,464 +1,30 @@ import SwiftUI -import SafariServices -import Combine +/// Container view that routes to the appropriate BookmarkDetail implementation +/// based on iOS version availability or user preference struct BookmarkDetailView: View { let bookmarkId: String - - // MARK: - States - - @State private var viewModel: BookmarkDetailViewModel - @State private var webViewHeight: CGFloat = 300 - @State private var showingFontSettings = false - @State private var showingLabelsSheet = false - @State private var readingProgress: Double = 0.0 - @State private var scrollViewHeight: CGFloat = 1 - @State private var showJumpToProgressButton: Bool = false - @State private var scrollPosition = ScrollPosition(edge: .top) - @State private var showingImageViewer = false - - // MARK: - Envs - - @EnvironmentObject var playerUIState: PlayerUIState - @EnvironmentObject var appSettings: AppSettings - @Environment(\.dismiss) private var dismiss - - private let headerHeight: CGFloat = 360 - - 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 - } - + + @AppStorage("useNativeWebView") private var useNativeWebView: Bool = true + var body: some View { - VStack(spacing: 0) { - ProgressView(value: readingProgress) - .progressViewStyle(LinearProgressViewStyle()) - .frame(height: 3) - GeometryReader { geometry in - ScrollView { - VStack(spacing: 0) { - ZStack(alignment: .top) { - headerView(width: geometry.size.width) - VStack(alignment: .leading, spacing: 16) { - Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight) - titleSection - Divider().padding(.horizontal) - if showJumpToProgressButton { - JumpButton() - } - if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { - WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in - if webViewHeight != height { - webViewHeight = height - } - }) - .frame(height: webViewHeight) - .cornerRadius(14) - .padding(.horizontal, 4) - } else if viewModel.isLoadingArticle { - ProgressView("Loading article...") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else { - Button(action: { - URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) - }) { - HStack { - Image(systemName: "safari") - Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page")) - } - .font(.title3.bold()) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .padding(.horizontal) - .padding(.top, 0) - } - - if viewModel.isLoadingArticle == false && viewModel.isLoading == false { - VStack(alignment: .center) { - archiveSection - .transition(.opacity.combined(with: .move(edge: .bottom))) - .animation(.easeInOut, value: viewModel.articleContent) - } - .frame(maxWidth: .infinity) - } - } - .frame(maxWidth: .infinity) - } - } - } - .ignoresSafeArea(edges: .top) - .scrollPosition($scrollPosition) - .onScrollGeometryChange(for: CGFloat.self) { geo in - geo.contentOffset.y - } action: { oldValue, newValue in - // Early exit: only process if scroll changed significantly (> 50px) - guard abs(newValue - oldValue) > 50 else { return } - - let offset = newValue - let maxOffset = webViewHeight - geometry.size.height - let rawProgress = offset / (maxOffset > 0 ? maxOffset : 1) - let progress = min(max(rawProgress, 0), 1) - - // Only update if change is significant (> 5%) to avoid lag - let threshold: Double = 0.05 - if abs(progress - readingProgress) > threshold { - readingProgress = progress - - // Always update backend (debounced internally) - viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil) - } - } - .onScrollGeometryChange(for: CGFloat.self) { geo in - geo.containerSize.height - } action: { oldValue, newValue in - scrollViewHeight = newValue - } + if #available(iOS 26.0, *) { + if useNativeWebView { + // Use modern SwiftUI-native implementation on iOS 26+ + BookmarkDetailView2(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView) + } else { + // Use legacy WKWebView-based implementation + BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView) } - } - .frame(maxWidth: .infinity) - .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) - } - .sheet(isPresented: $showingImageViewer) { - ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) - } - .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) - } - } - } - .onChange(of: viewModel.readProgress) { _, progress in - showJumpToProgressButton = progress > 0 && progress < 100 - } - .task { - await viewModel.loadBookmarkDetail(id: bookmarkId) - await viewModel.loadArticleContent(id: bookmarkId) - } - } - - // MARK: - ViewBuilder - - @ViewBuilder - private func headerView(width: CGFloat) -> some View { - if !viewModel.bookmarkDetail.imageUrl.isEmpty { - ZStack(alignment: .bottomTrailing) { - CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) - .aspectRatio(contentMode: .fill) - .frame(width: width, height: headerHeight) - .clipped() - - // Zoom icon - Button(action: { - showingImageViewer = true - }) { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) - .padding(8) - .background( - Circle() - .fill(Color.black.opacity(0.6)) - .overlay( - Circle() - .stroke(Color.white.opacity(0.3), lineWidth: 1) - ) - ) - } - .padding(.trailing, 16) - .padding(.bottom, 16) - } - .frame(height: headerHeight) - .ignoresSafeArea(edges: .top) - .onTapGesture { - showingImageViewer = true - } - } - } - - 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(maxWidth: .infinity) - .frame(height: webViewHeight) - .cornerRadius(14) - .padding(.horizontal, 4) - .animation(.easeInOut, value: webViewHeight) - } else if viewModel.isLoadingArticle { - ProgressView("Loading article...") - .frame(maxWidth: .infinity, alignment: .center) - .padding() } else { - Button(action: { - URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) - }) { - 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) + // iOS < 26: always use Legacy + BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: .constant(false)) } } - - 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: { - URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) - }) { - 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 = .autoupdatingCurrent - 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, isArchive: !viewModel.bookmarkDetail.isArchived) - } - }) { - HStack { - Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox") - Text(viewModel.bookmarkDetail.isArchived ? "Unarchive Bookmark" : "Archive bookmark") - } - .font(.title3.bold()) - .frame(maxHeight: 60) - .padding(10) - } - .buttonStyle(.borderedProminent) - .disabled(viewModel.isLoading) - } - if let error = viewModel.errorMessage { - Text(error) - .foregroundColor(.red) - .font(.footnote) - } - } - .padding(.horizontal) - .padding(.bottom, 32) - } - - @ViewBuilder - func JumpButton() -> some View { - Button(action: { - let maxOffset = webViewHeight - scrollViewHeight - let offset = maxOffset * (Double(viewModel.readProgress) / 100.0) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - scrollPosition = ScrollPosition(y: offset) - showJumpToProgressButton = false - } - }) { - Text("Jump to last read position (\(viewModel.readProgress)%)") - .font(.subheadline) - .padding(8) - .frame(maxWidth: .infinity) - } - .background(Color.accentColor.opacity(0.15)) - .cornerRadius(8) - .padding([.top, .horizontal]) - } } #Preview { NavigationView { - BookmarkDetailView(bookmarkId: "123", - viewModel: .init(MockUseCaseFactory()), - webViewHeight: 300, - showingFontSettings: false, - showingLabelsSheet: false, - playerUIState: .init()) + BookmarkDetailView(bookmarkId: "123") } } diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift new file mode 100644 index 0000000..c277ec6 --- /dev/null +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -0,0 +1,453 @@ +import SwiftUI +import SafariServices + +@available(iOS 26.0, *) +struct BookmarkDetailView2: View { + let bookmarkId: String + @Binding var useNativeWebView: Bool + + // MARK: - States + + @State private var viewModel: BookmarkDetailViewModel + @State private var webViewHeight: CGFloat = 300 + @State private var showingFontSettings = false + @State private var showingLabelsSheet = false + @State private var readingProgress: Double = 0.0 + @State private var scrollViewHeight: CGFloat = 1 + @State private var currentScrollOffset: CGFloat = 0 + @State private var showJumpToProgressButton: Bool = false + @State private var scrollPosition = ScrollPosition(edge: .top) + @State private var showingImageViewer = false + + // MARK: - Envs + + @EnvironmentObject var playerUIState: PlayerUIState + @EnvironmentObject var appSettings: AppSettings + @Environment(\.dismiss) private var dismiss + + private let headerHeight: CGFloat = 360 + + init(bookmarkId: String, useNativeWebView: Binding, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) { + self.bookmarkId = bookmarkId + self._useNativeWebView = useNativeWebView + self.viewModel = viewModel + } + + var body: some View { + mainView + } + + private var mainView: some View { + VStack(spacing: 0) { + // Progress bar at top + ProgressView(value: readingProgress) + .progressViewStyle(LinearProgressViewStyle()) + .frame(height: 3) + + // Main scroll content + scrollViewContent + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + toolbarContent + } + .sheet(isPresented: $showingFontSettings) { + fontSettingsSheet + } + .sheet(isPresented: $showingLabelsSheet) { + BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels) + } + .sheet(isPresented: $showingImageViewer) { + ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) + } + .onChange(of: showingFontSettings) { _, isShowing in + if !isShowing { + Task { + await viewModel.loadBookmarkDetail(id: bookmarkId) + } + } + } + .onChange(of: showingLabelsSheet) { _, isShowing in + if !isShowing { + Task { + await viewModel.refreshBookmarkDetail(id: bookmarkId) + } + } + } + .onChange(of: viewModel.readProgress) { _, progress in + showJumpToProgressButton = progress > 0 && progress < 100 + } + .task { + await viewModel.loadBookmarkDetail(id: bookmarkId) + await viewModel.loadArticleContent(id: bookmarkId) + } + } + + private var scrollViewContent: some View { + ScrollView { + VStack(spacing: 0) { + // Header image + headerView + + // Content + VStack(alignment: .leading, spacing: 16) { + // Spacer for header + Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight) + + // Title section + titleSection + + Divider().padding(.horizontal) + + // Jump to last position button + if showJumpToProgressButton { + jumpButton + } + + // Article content (WebView) + articleContent + } + .frame(maxWidth: .infinity) + } + } + .clipped() + .ignoresSafeArea(edges: .top) + .scrollPosition($scrollPosition) + .onScrollGeometryChange(for: CGFloat.self) { geo in + geo.contentOffset.y + } action: { oldValue, newValue in + // Just track current offset, don't calculate yet + currentScrollOffset = newValue + } + .onScrollGeometryChange(for: CGFloat.self) { geo in + geo.containerSize.height + } action: { oldValue, newValue in + scrollViewHeight = newValue + } + .onScrollPhaseChange { oldPhase, newPhase in + // Only calculate progress when scrolling ends + if oldPhase == .interacting && newPhase == .idle { + let offset = currentScrollOffset + let maxOffset = webViewHeight - scrollViewHeight + let rawProgress = offset / (maxOffset > 0 ? maxOffset : 1) + let progress = min(max(rawProgress, 0), 1) + + // Only update if change is significant (> 5%) + let threshold: Double = 0.05 + if abs(progress - readingProgress) > threshold { + readingProgress = progress + viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil) + } + } + } + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + // Toggle button (left) + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + useNativeWebView.toggle() + }) { + Image(systemName: "sparkles") + .foregroundColor(.accentColor) + } + } + + // Top toolbar (right) + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 12) { + Button(action: { + showingLabelsSheet = true + }) { + Image(systemName: "tag") + } + + Button(action: { + showingFontSettings = true + }) { + Image(systemName: "textformat") + } + } + } + + // Bottom toolbar - Archive section + if viewModel.isLoadingArticle == false && viewModel.isLoading == false { + ToolbarItemGroup(placement: .bottomBar) { + + Spacer() + + Button(action: { + Task { + await viewModel.toggleFavorite(id: bookmarkId) + } + }) { + Label( + viewModel.bookmarkDetail.isMarked ? "Favorited" : "Favorite", + systemImage: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star" + ) + } + .disabled(viewModel.isLoading) + + Button(action: { + Task { + await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived) + } + }) { + Label( + viewModel.bookmarkDetail.isArchived ? "Unarchive" : "Archive", + systemImage: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox" + ) + } + .disabled(viewModel.isLoading) + } + } + } + + private var fontSettingsSheet: some View { + 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 + } + } + } + } + } + + // MARK: - ViewBuilder + + @ViewBuilder + private var headerView: some View { + if !viewModel.bookmarkDetail.imageUrl.isEmpty { + ZStack(alignment: .bottomTrailing) { + CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) + .aspectRatio(contentMode: .fill) + .frame(height: headerHeight) + .clipped() + + // Zoom icon + Button(action: { + showingImageViewer = true + }) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .padding(8) + .background( + Circle() + .fill(Color.black.opacity(0.6)) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + ) + } + .padding(.trailing, 16) + .padding(.bottom, 16) + } + .frame(height: headerHeight) + .ignoresSafeArea(edges: .top) + .onTapGesture { + showingImageViewer = true + } + } + } + + 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) + } + + 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: { + URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) + }) { + 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() + } + } + + @ViewBuilder + private var articleContent: some View { + if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { + if #available(iOS 26.0, *) { + NativeWebView( + htmlContent: viewModel.articleContent, + settings: settings, + onHeightChange: { height in + if webViewHeight != height { + webViewHeight = height + } + } + ) + .frame(maxWidth: .infinity) + .frame(height: webViewHeight) + .cornerRadius(14) + .padding(.horizontal, 4) + } + } else if viewModel.isLoadingArticle { + ProgressView("Loading article...") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else { + Button(action: { + URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) + }) { + HStack { + Image(systemName: "safari") + Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page")) + } + .font(.title3.bold()) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal) + .padding(.top, 0) + } + } + + private var jumpButton: some View { + Button(action: { + let maxOffset = webViewHeight - scrollViewHeight + let offset = maxOffset * (Double(viewModel.readProgress) / 100.0) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + scrollPosition = ScrollPosition(y: offset) + showJumpToProgressButton = false + } + }) { + Text("Jump to last read position (\(viewModel.readProgress)%)") + .font(.subheadline) + .padding(8) + .frame(maxWidth: .infinity) + } + .background(Color.accentColor.opacity(0.15)) + .cornerRadius(8) + .padding([.top, .horizontal]) + } + + 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 = .autoupdatingCurrent + return displayFormatter.string(from: date) + } + return dateString + } +} + +#Preview { + if #available(iOS 26.0, *) { + NavigationView { + BookmarkDetailView2( + bookmarkId: "123", + useNativeWebView: .constant(true), + viewModel: .init(MockUseCaseFactory()) + ) + } + } +} diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift new file mode 100644 index 0000000..6aa7ddb --- /dev/null +++ b/readeck/UI/Components/NativeWebView.swift @@ -0,0 +1,302 @@ +import SwiftUI +import WebKit + +// MARK: - iOS 26+ Native SwiftUI WebView Implementation +// This implementation is available but not currently used +// To activate: Replace WebView usage with hybrid approach using #available(iOS 26.0, *) + +@available(iOS 26.0, *) +struct NativeWebView: View { + let htmlContent: String + let settings: Settings + let onHeightChange: (CGFloat) -> Void + var onScroll: ((Double) -> Void)? = nil + + @State private var webPage = WebPage() + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + WebKit.WebView(webPage) + .scrollDisabled(true) // Disable internal scrolling + .onAppear { + loadStyledContent() + } + .onChange(of: htmlContent) { _, _ in + loadStyledContent() + } + .onChange(of: colorScheme) { _, _ in + loadStyledContent() + } + .onChange(of: webPage.isLoading) { _, isLoading in + if !isLoading { + // Update height when content finishes loading + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + Task { + await updateContentHeightWithJS() + } + } + } + } + } + + private func updateContentHeightWithJS() async { + var lastHeight: CGFloat = 0 + + // Similar strategy to WebView: multiple attempts with increasing delays + let delays = [0.1, 0.2, 0.5, 1.0, 1.5, 2.0] // 6 attempts like WebView + + for (index, delay) in delays.enumerated() { + let attempt = index + 1 + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + do { + // Try to get height via JavaScript - use direct expression instead of function call + let result = try await webPage.callJavaScript(""" + Math.max( + document.body.scrollHeight || 0, + document.body.offsetHeight || 0, + document.documentElement.clientHeight || 0, + document.documentElement.scrollHeight || 0, + document.documentElement.offsetHeight || 0 + ) + """) + + if let height = result as? Double, height > 0 { + let cgHeight = CGFloat(height) + + // Update height if it's significantly different (> 5px like WebView) + if lastHeight == 0 || abs(cgHeight - lastHeight) > 5 { + print("🟢 NativeWebView - JavaScript height updated: \(height)px on attempt \(attempt)") + DispatchQueue.main.async { + self.onHeightChange(cgHeight) + } + lastHeight = cgHeight + } + + // If height seems stable (no change in last 2 attempts), we can exit early + if attempt >= 2 && lastHeight > 0 { + print("🟢 NativeWebView - Height stabilized at \(lastHeight)px after \(attempt) attempts") + return + } + } + } catch { + print("🟡 NativeWebView - JavaScript attempt \(attempt) failed: \(error)") + } + } + + // If no valid height was found, use fallback + if lastHeight == 0 { + print("🔴 NativeWebView - No valid JavaScript height found, using fallback") + updateContentHeightFallback() + } else { + print("🟢 NativeWebView - Final height: \(lastHeight)px") + } + } + + private func updateContentHeightFallback() { + // Simplified fallback calculation + let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) + let plainText = htmlContent.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + let characterCount = plainText.count + let estimatedLines = max(1, characterCount / 80) + let textHeight = CGFloat(estimatedLines) * CGFloat(fontSize) * 1.8 + let finalHeight = max(400, min(textHeight + 100, 3000)) + + print("🟡 NativeWebView - Using fallback height: \(finalHeight)px") + DispatchQueue.main.async { + self.onHeightChange(finalHeight) + } + } + + private func loadStyledContent() { + let isDarkMode = colorScheme == .dark + let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) + let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif) + + let styledHTML = """ + + + + + + + + \(htmlContent) + + + + """ + webPage.load(html: styledHTML) + + // Update height after content loads + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Task { + await updateContentHeightWithJS() + } + } + } + + private func getFontSize(from fontSize: FontSize) -> Int { + switch fontSize { + case .small: return 14 + case .medium: return 16 + case .large: return 18 + case .extraLarge: return 20 + } + } + + private func getFontFamily(from fontFamily: FontFamily) -> String { + switch fontFamily { + case .system: return "-apple-system, BlinkMacSystemFont, sans-serif" + case .serif: return "'Times New Roman', Times, serif" + case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif" + case .monospace: return "'SF Mono', Menlo, Monaco, monospace" + } + } +} + +// MARK: - Hybrid WebView (Not Currently Used) +// This would be the implementation to use both native and legacy WebViews +// Currently commented out - the app uses only the crash-resistant WebView + +/* +struct HybridWebView: View { + let htmlContent: String + let settings: Settings + let onHeightChange: (CGFloat) -> Void + var onScroll: ((Double) -> Void)? = nil + + var body: some View { + if #available(iOS 26.0, *) { + // Use new native SwiftUI WebView on iOS 26+ + NativeWebView( + htmlContent: htmlContent, + settings: settings, + onHeightChange: onHeightChange, + onScroll: onScroll + ) + } else { + // Fallback to crash-resistant WebView for older iOS + WebView( + htmlContent: htmlContent, + settings: settings, + onHeightChange: onHeightChange, + onScroll: onScroll + ) + } + } +} +*/ \ No newline at end of file