feat: Add native SwiftUI WebView support with iOS 26+ BookmarkDetailView2
- Created BookmarkDetailView2 with native SwiftUI WebView (iOS 26+) - Refactored BookmarkDetailView as version router - Renamed original implementation to BookmarkDetailLegacyView - Moved Archive/Favorite buttons to bottom toolbar using ToolbarItemGroup - Added toggle button to switch between native and legacy views - Implemented onScrollPhaseChange for optimized reading progress tracking - Added NativeWebView component with improved JavaScript height detection - All changes preserve existing functionality while adding modern alternatives
This commit is contained in:
parent
6addacb1d9
commit
171bf881fb
478
readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift
Normal file
478
readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift
Normal file
@ -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<Bool>, 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())
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
453
readeck/UI/BookmarkDetail/BookmarkDetailView2.swift
Normal file
453
readeck/UI/BookmarkDetail/BookmarkDetailView2.swift
Normal file
@ -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<Bool>, 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())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
302
readeck/UI/Components/NativeWebView.swift
Normal file
302
readeck/UI/Components/NativeWebView.swift
Normal file
@ -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 = """
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
|
||||
<style>
|
||||
body {
|
||||
font-family: \(fontFamily);
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background-color: \(isDarkMode ? "#000000" : "#ffffff");
|
||||
color: \(isDarkMode ? "#ffffff" : "#1a1a1a");
|
||||
font-size: \(fontSize)px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
overflow: hidden; /* Disable scrolling in WebView */
|
||||
height: auto;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden; /* Disable scrolling */
|
||||
height: auto;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: \(isDarkMode ? "#ffffff" : "#000000");
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 { font-size: \(fontSize * 3 / 2)px; }
|
||||
h2 { font-size: \(fontSize * 5 / 4)px; }
|
||||
h3 { font-size: \(fontSize * 9 / 8)px; }
|
||||
|
||||
p { margin-bottom: 16px; }
|
||||
|
||||
img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px 0; }
|
||||
a { color: \(isDarkMode ? "#0A84FF" : "#007AFF"); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid \(isDarkMode ? "#0A84FF" : "#007AFF");
|
||||
margin: 16px 0;
|
||||
padding: 12px 16px;
|
||||
font-style: italic;
|
||||
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.3)" : "rgba(0, 122, 255, 0.05)");
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
|
||||
color: \(isDarkMode ? "#ffffff" : "#000000");
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
|
||||
color: \(isDarkMode ? "#ffffff" : "#000000");
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
ul, ol { padding-left: 20px; margin-bottom: 16px; }
|
||||
li { margin-bottom: 4px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
||||
th, td { border: 1px solid #ccc; padding: 8px 12px; text-align: left; }
|
||||
th { font-weight: 600; }
|
||||
|
||||
hr { border: none; height: 1px; background-color: #ccc; margin: 24px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
\(htmlContent)
|
||||
<script>
|
||||
function measureHeight() {
|
||||
return Math.max(
|
||||
document.body.scrollHeight || 0,
|
||||
document.body.offsetHeight || 0,
|
||||
document.documentElement.clientHeight || 0,
|
||||
document.documentElement.scrollHeight || 0,
|
||||
document.documentElement.offsetHeight || 0
|
||||
);
|
||||
}
|
||||
|
||||
// Make function globally available
|
||||
window.getContentHeight = measureHeight;
|
||||
|
||||
// Auto-measure when everything is ready
|
||||
function scheduleHeightCheck() {
|
||||
// Multiple timing strategies
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', delayedHeightCheck);
|
||||
} else {
|
||||
delayedHeightCheck();
|
||||
}
|
||||
|
||||
// Also check after images load
|
||||
window.addEventListener('load', delayedHeightCheck);
|
||||
|
||||
// Force check after layout
|
||||
setTimeout(delayedHeightCheck, 50);
|
||||
setTimeout(delayedHeightCheck, 100);
|
||||
setTimeout(delayedHeightCheck, 200);
|
||||
setTimeout(delayedHeightCheck, 500);
|
||||
}
|
||||
|
||||
function delayedHeightCheck() {
|
||||
// Force layout recalculation
|
||||
document.body.offsetHeight;
|
||||
const height = measureHeight();
|
||||
console.log('NativeWebView height check:', height);
|
||||
}
|
||||
|
||||
scheduleHeightCheck();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
Loading…
x
Reference in New Issue
Block a user