Changed headerView from var to func with width parameter, matching LegacyView. Added .frame(width: width, height: headerHeight) to constrain header image width. This was the root cause of content overflow - without explicit width on the header image, the entire ZStack and its children (including title and webview) could grow beyond viewport width. Now matches LegacyView implementation exactly.
458 lines
16 KiB
Swift
458 lines
16 KiB
Swift
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 lastSentProgress: Double = 0.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
|
|
}
|
|
.toolbarBackgroundVisibility(.hidden, for: .bottomBar)
|
|
.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 {
|
|
GeometryReader { geometry in
|
|
ScrollView {
|
|
// Invisible GeometryReader to track scroll offset
|
|
GeometryReader { scrollGeo in
|
|
Color.clear.preference(
|
|
key: ScrollOffsetPreferenceKey.self,
|
|
value: CGPoint(
|
|
x: scrollGeo.frame(in: .named("scrollView")).minX,
|
|
y: scrollGeo.frame(in: .named("scrollView")).minY
|
|
)
|
|
)
|
|
}
|
|
.frame(height: 0)
|
|
|
|
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(containerHeight: geometry.size.height)
|
|
}
|
|
|
|
// Article content (WebView)
|
|
articleContent
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
.coordinateSpace(name: "scrollView")
|
|
.clipped()
|
|
.ignoresSafeArea(edges: .top)
|
|
.scrollPosition($scrollPosition)
|
|
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
|
|
// Calculate progress from scroll offset
|
|
let scrollOffset = -offset.y
|
|
let containerHeight = geometry.size.height
|
|
let maxOffset = webViewHeight - containerHeight
|
|
|
|
guard maxOffset > 0 else { return }
|
|
|
|
let rawProgress = scrollOffset / maxOffset
|
|
let progress = min(max(rawProgress, 0), 1)
|
|
|
|
// Only update if change >= 3%
|
|
let threshold: Double = 0.03
|
|
if abs(progress - lastSentProgress) >= threshold {
|
|
lastSentProgress = progress
|
|
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 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(width: width, 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(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 func jumpButton(containerHeight: CGFloat) -> some View {
|
|
Button(action: {
|
|
let maxOffset = webViewHeight - containerHeight
|
|
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())
|
|
)
|
|
}
|
|
}
|
|
}
|