Compare commits
No commits in common. "f302f8800f92decf5da8477cca3920d09b3fda6b" and "a2c805b7004e079d541034433e081a97677e73fd" have entirely different histories.
f302f8800f
...
a2c805b700
@ -437,7 +437,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 25;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -450,7 +450,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -470,7 +470,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 25;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -483,7 +483,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -625,7 +625,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 25;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -648,7 +648,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -669,7 +669,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 25;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -692,7 +692,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@ -1,549 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import SafariServices
|
|
||||||
|
|
||||||
// PreferenceKey for scroll offset tracking
|
|
||||||
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
|
||||||
static var defaultValue: CGPoint = .zero
|
|
||||||
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
|
|
||||||
value = nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreferenceKey for content height tracking
|
|
||||||
struct ContentHeightPreferenceKey: PreferenceKey {
|
|
||||||
static var defaultValue: CGFloat = 0
|
|
||||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
||||||
value = nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 contentEndPosition: CGFloat = 0
|
|
||||||
@State private var initialContentEndPosition: CGFloat = 0
|
|
||||||
@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 {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ProgressView(value: readingProgress)
|
|
||||||
.progressViewStyle(LinearProgressViewStyle())
|
|
||||||
.frame(height: 3)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invisible marker to measure total content height - placed AFTER all content
|
|
||||||
Color.clear
|
|
||||||
.frame(height: 1)
|
|
||||||
.background(
|
|
||||||
GeometryReader { endGeo in
|
|
||||||
Color.clear.preference(
|
|
||||||
key: ContentHeightPreferenceKey.self,
|
|
||||||
value: endGeo.frame(in: .named("scrollView")).maxY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.coordinateSpace(name: "scrollView")
|
|
||||||
.clipped()
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
.scrollPosition($scrollPosition)
|
|
||||||
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
|
|
||||||
contentEndPosition = endPosition
|
|
||||||
|
|
||||||
let containerHeight = geometry.size.height
|
|
||||||
|
|
||||||
// Update initial position if content grows (WebView still loading) or first time
|
|
||||||
// We always take the maximum position seen (when scrolled to top, this is total content height)
|
|
||||||
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
|
|
||||||
initialContentEndPosition = endPosition
|
|
||||||
print("📏 Content end position updated: \(Int(endPosition)) (container: \(Int(containerHeight)))")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate progress from how much the end marker has moved up
|
|
||||||
guard initialContentEndPosition > 0 else {
|
|
||||||
print("⏳ Waiting for content to load... current: \(Int(endPosition)), container: \(Int(containerHeight))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalScrollableDistance = initialContentEndPosition - containerHeight
|
|
||||||
|
|
||||||
guard totalScrollableDistance > 0 else {
|
|
||||||
print("⚠️ Content not scrollable: initial=\(initialContentEndPosition), container=\(containerHeight)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// How far has the marker moved from its initial position?
|
|
||||||
let scrolled = initialContentEndPosition - endPosition
|
|
||||||
let rawProgress = scrolled / totalScrollableDistance
|
|
||||||
var progress = min(max(rawProgress, 0), 1)
|
|
||||||
|
|
||||||
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
|
|
||||||
if lastSentProgress >= 0.995 {
|
|
||||||
progress = max(progress, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
print("📊 Progress: \(Int(progress * 100))% | scrolled: \(Int(scrolled)) / \(Int(totalScrollableDistance)) | endPos: \(Int(endPosition))")
|
|
||||||
|
|
||||||
// Check if we should update: threshold OR reaching 100% for first time
|
|
||||||
let threshold: Double = 0.03
|
|
||||||
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
|
|
||||||
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
|
|
||||||
|
|
||||||
if shouldUpdate {
|
|
||||||
print("✅ Updating progress: \(Int(lastSentProgress * 100))% → \(Int(progress * 100))%\(reachedEnd ? " [END]" : "")")
|
|
||||||
lastSentProgress = progress
|
|
||||||
readingProgress = progress
|
|
||||||
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
|
|
||||||
// Not needed anymore, we track via ContentHeightPreferenceKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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(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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
NavigationView {
|
|
||||||
BookmarkDetailLegacyView(
|
|
||||||
bookmarkId: "123",
|
|
||||||
useNativeWebView: .constant(false),
|
|
||||||
viewModel: .init(MockUseCaseFactory())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +1,471 @@
|
|||||||
import SwiftUI
|
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 {
|
struct BookmarkDetailView: View {
|
||||||
let bookmarkId: String
|
let bookmarkId: String
|
||||||
|
|
||||||
@AppStorage("useNativeWebView") private var useNativeWebView: Bool = true
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 26.0, *) {
|
VStack(spacing: 0) {
|
||||||
if useNativeWebView {
|
ProgressView(value: readingProgress)
|
||||||
// Use modern SwiftUI-native implementation on iOS 26+
|
.progressViewStyle(LinearProgressViewStyle())
|
||||||
BookmarkDetailView2(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView)
|
.frame(height: 3)
|
||||||
} else {
|
GeometryReader { outerGeo in
|
||||||
// Use legacy WKWebView-based implementation
|
ScrollView {
|
||||||
BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView)
|
VStack(spacing: 0) {
|
||||||
|
GeometryReader { geo in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ScrollOffsetPreferenceKey.self,
|
||||||
|
value: geo.frame(in: .named("scroll")).minY)
|
||||||
|
}
|
||||||
|
.frame(height: 0)
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
headerView(geometry: outerGeo)
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Color.clear.frame(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.coordinateSpace(name: "scroll")
|
||||||
|
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
|
||||||
|
scrollViewHeight = outerGeo.size.height
|
||||||
|
let maxOffset = webViewHeight - scrollViewHeight
|
||||||
|
let rawProgress = -offset / (maxOffset != 0 ? maxOffset : 1)
|
||||||
|
let progress = min(max(rawProgress, 0), 1)
|
||||||
|
readingProgress = progress
|
||||||
|
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.scrollPosition($scrollPosition)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// iOS < 26: always use Legacy
|
|
||||||
BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: .constant(false))
|
|
||||||
}
|
}
|
||||||
|
.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(geometry: GeometryProxy) -> some View {
|
||||||
|
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let offset = geo.frame(in: .global).minY
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||||
|
.clipped()
|
||||||
|
.offset(y: (offset > 0 ? -offset : 0))
|
||||||
|
|
||||||
|
// Tap area and zoom icon
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
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 + (offset > 0 ? offset : 0))
|
||||||
|
.offset(y: (offset > 0 ? -offset : 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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(height: webViewHeight)
|
||||||
|
.cornerRadius(14)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.animation(.easeInOut, value: webViewHeight)
|
||||||
|
} else if viewModel.isLoadingArticle {
|
||||||
|
ProgressView("Loading article...")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||||
|
typealias Value = CGFloat
|
||||||
|
static var defaultValue = CGFloat.zero
|
||||||
|
static func reduce(value: inout Value, nextValue: () -> Value) {
|
||||||
|
value += nextValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
BookmarkDetailView(bookmarkId: "123")
|
BookmarkDetailView(bookmarkId: "123",
|
||||||
|
viewModel: .init(MockUseCaseFactory()),
|
||||||
|
webViewHeight: 300,
|
||||||
|
showingFontSettings: false,
|
||||||
|
showingLabelsSheet: false,
|
||||||
|
playerUIState: .init())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,498 +0,0 @@
|
|||||||
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 contentEndPosition: CGFloat = 0
|
|
||||||
@State private var initialContentEndPosition: CGFloat = 0
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invisible marker to measure total content height - placed AFTER all content
|
|
||||||
Color.clear
|
|
||||||
.frame(height: 1)
|
|
||||||
.background(
|
|
||||||
GeometryReader { endGeo in
|
|
||||||
Color.clear.preference(
|
|
||||||
key: ContentHeightPreferenceKey.self,
|
|
||||||
value: endGeo.frame(in: .named("scrollView")).maxY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.coordinateSpace(name: "scrollView")
|
|
||||||
.clipped()
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
.scrollPosition($scrollPosition)
|
|
||||||
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
|
|
||||||
contentEndPosition = endPosition
|
|
||||||
|
|
||||||
let containerHeight = geometry.size.height
|
|
||||||
|
|
||||||
// Update initial position if content grows (WebView still loading) or first time
|
|
||||||
// We always take the maximum position seen (when scrolled to top, this is total content height)
|
|
||||||
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
|
|
||||||
initialContentEndPosition = endPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate progress from how much the end marker has moved up
|
|
||||||
guard initialContentEndPosition > 0 else { return }
|
|
||||||
|
|
||||||
let totalScrollableDistance = initialContentEndPosition - containerHeight
|
|
||||||
|
|
||||||
guard totalScrollableDistance > 0 else { return }
|
|
||||||
|
|
||||||
// How far has the marker moved from its initial position?
|
|
||||||
let scrolled = initialContentEndPosition - endPosition
|
|
||||||
let rawProgress = scrolled / totalScrollableDistance
|
|
||||||
var progress = min(max(rawProgress, 0), 1)
|
|
||||||
|
|
||||||
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
|
|
||||||
if lastSentProgress >= 0.995 {
|
|
||||||
progress = max(progress, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should update: threshold OR reaching 100% for first time
|
|
||||||
let threshold: Double = 0.03
|
|
||||||
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
|
|
||||||
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
|
|
||||||
|
|
||||||
if shouldUpdate {
|
|
||||||
lastSentProgress = progress
|
|
||||||
readingProgress = progress
|
|
||||||
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
|
|
||||||
// Not needed anymore, we track via ContentHeightPreferenceKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
|
||||||
private var toolbarContent: some ToolbarContent {
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
// Toggle button (left)
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
Button(action: {
|
|
||||||
useNativeWebView.toggle()
|
|
||||||
}) {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Top toolbar (right)
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button(action: {
|
|
||||||
showingLabelsSheet = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showingFontSettings = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "textformat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,309 +0,0 @@
|
|||||||
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 simple document.body.scrollHeight
|
|
||||||
let result = try await webPage.callJavaScript("return document.body.scrollHeight")
|
|
||||||
|
|
||||||
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, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
overflow-x: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
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-x: hidden;
|
|
||||||
width: 100%;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
max-width: 100%;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
@ -21,43 +21,27 @@ struct WebView: UIViewRepresentable {
|
|||||||
webView.scrollView.isScrollEnabled = false
|
webView.scrollView.isScrollEnabled = false
|
||||||
webView.isOpaque = false
|
webView.isOpaque = false
|
||||||
webView.backgroundColor = UIColor.clear
|
webView.backgroundColor = UIColor.clear
|
||||||
|
|
||||||
// Allow text selection and copying
|
// Allow text selection and copying
|
||||||
webView.allowsBackForwardNavigationGestures = false
|
webView.allowsBackForwardNavigationGestures = false
|
||||||
webView.allowsLinkPreview = true
|
webView.allowsLinkPreview = true
|
||||||
|
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
|
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
|
|
||||||
let isDarkMode = colorScheme == .dark
|
let isDarkMode = colorScheme == .dark
|
||||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||||
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
|
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
|
||||||
|
|
||||||
// Clean up problematic HTML that kills performance
|
|
||||||
let cleanedHTML = htmlContent
|
|
||||||
// Remove Google attributes that cause navigation events
|
|
||||||
.replacingOccurrences(of: #"\s*jsaction="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
.replacingOccurrences(of: #"\s*jscontroller="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
.replacingOccurrences(of: #"\s*jsname="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
// Remove unnecessary IDs that bloat the DOM
|
|
||||||
.replacingOccurrences(of: #"\s*id="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
// Remove tabindex from non-interactive elements
|
|
||||||
.replacingOccurrences(of: #"\s*tabindex="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
// Remove role=button from figures (causes false click targets)
|
|
||||||
.replacingOccurrences(of: #"\s*role="button""#, with: "", options: .regularExpression)
|
|
||||||
// Fix invalid nested <p> tags inside <pre><span>
|
|
||||||
.replacingOccurrences(of: #"<pre><span[^>]*>([^<]*)<p>"#, with: "<pre><span>$1\n", options: .regularExpression)
|
|
||||||
.replacingOccurrences(of: #"</p>([^<]*)</span></pre>"#, with: "\n$1</span></pre>", options: .regularExpression)
|
|
||||||
|
|
||||||
let styledHTML = """
|
let styledHTML = """
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@ -238,7 +222,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
\(cleanedHTML)
|
\(htmlContent)
|
||||||
<script>
|
<script>
|
||||||
let lastHeight = 0;
|
let lastHeight = 0;
|
||||||
let heightUpdateTimeout = null;
|
let heightUpdateTimeout = null;
|
||||||
@ -264,6 +248,22 @@ struct WebView: UIViewRepresentable {
|
|||||||
document.querySelectorAll('img').forEach(img => {
|
document.querySelectorAll('img').forEach(img => {
|
||||||
img.addEventListener('load', debouncedHeightUpdate);
|
img.addEventListener('load', debouncedHeightUpdate);
|
||||||
});
|
});
|
||||||
|
window.addEventListener('scroll', function() {
|
||||||
|
isScrolling = true;
|
||||||
|
clearTimeout(scrollTimeout);
|
||||||
|
|
||||||
|
scrollTimeout = setTimeout(function() {
|
||||||
|
var scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||||
|
var docHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
||||||
|
var progress = docHeight > 0 ? scrollTop / docHeight : 0;
|
||||||
|
window.webkit.messageHandlers.scrollProgress.postMessage(progress);
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
isScrolling = false;
|
||||||
|
debouncedHeightUpdate();
|
||||||
|
}, 200);
|
||||||
|
}, 16);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -311,18 +311,18 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
// Callbacks
|
// Callbacks
|
||||||
var onHeightChange: ((CGFloat) -> Void)?
|
var onHeightChange: ((CGFloat) -> Void)?
|
||||||
var onScroll: ((Double) -> Void)?
|
var onScroll: ((Double) -> Void)?
|
||||||
|
|
||||||
// Height management
|
// Height management
|
||||||
var lastHeight: CGFloat = 0
|
var lastHeight: CGFloat = 0
|
||||||
var pendingHeight: CGFloat = 0
|
var pendingHeight: CGFloat = 0
|
||||||
var heightUpdateTimer: Timer?
|
var heightUpdateTimer: Timer?
|
||||||
|
|
||||||
// Scroll management
|
// Scroll management
|
||||||
var isScrolling: Bool = false
|
var isScrolling: Bool = false
|
||||||
var scrollVelocity: Double = 0
|
var scrollVelocity: Double = 0
|
||||||
var lastScrollTime: Date = Date()
|
var lastScrollTime: Date = Date()
|
||||||
var scrollEndTimer: Timer?
|
var scrollEndTimer: Timer?
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
private var isCleanedUp = false
|
private var isCleanedUp = false
|
||||||
|
|
||||||
@ -370,23 +370,23 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
private func handleScrollProgress(progress: Double) {
|
private func handleScrollProgress(progress: Double) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let timeDelta = now.timeIntervalSince(lastScrollTime)
|
let timeDelta = now.timeIntervalSince(lastScrollTime)
|
||||||
|
|
||||||
// Calculate scroll velocity to detect fast scrolling
|
// Calculate scroll velocity to detect fast scrolling
|
||||||
if timeDelta > 0 {
|
if timeDelta > 0 {
|
||||||
scrollVelocity = abs(progress) / timeDelta
|
scrollVelocity = abs(progress) / timeDelta
|
||||||
}
|
}
|
||||||
|
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
isScrolling = true
|
isScrolling = true
|
||||||
|
|
||||||
// Longer delay for scroll end detection, especially during fast scrolling
|
// Longer delay for scroll end detection, especially during fast scrolling
|
||||||
let scrollEndDelay: TimeInterval = scrollVelocity > 2.0 ? 0.8 : 0.5
|
let scrollEndDelay: TimeInterval = scrollVelocity > 2.0 ? 0.8 : 0.5
|
||||||
|
|
||||||
scrollEndTimer?.invalidate()
|
scrollEndTimer?.invalidate()
|
||||||
scrollEndTimer = Timer.scheduledTimer(withTimeInterval: scrollEndDelay, repeats: false) { [weak self] _ in
|
scrollEndTimer = Timer.scheduledTimer(withTimeInterval: scrollEndDelay, repeats: false) { [weak self] _ in
|
||||||
self?.handleScrollEnd()
|
self?.handleScrollEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
onScroll?(progress)
|
onScroll?(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -411,11 +411,11 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
if heightDifference < 5 { // Ignore tiny height changes that cause flicker
|
if heightDifference < 5 { // Ignore tiny height changes that cause flicker
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lastHeight = height
|
lastHeight = height
|
||||||
onHeightChange?(height)
|
onHeightChange?(height)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanup() {
|
func cleanup() {
|
||||||
guard !isCleanedUp else { return }
|
guard !isCleanedUp else { return }
|
||||||
isCleanedUp = true
|
isCleanedUp = true
|
||||||
|
|||||||
@ -121,12 +121,11 @@ struct PhoneTabView: View {
|
|||||||
.badge(offlineBookmarksBadgeCount)
|
.badge(offlineBookmarksBadgeCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabBarMinimizeBehaviorIfAvailable()
|
|
||||||
.accentColor(.accentColor)
|
.accentColor(.accentColor)
|
||||||
.searchToolbarBehaviorIfAvailable()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Tab Content
|
// MARK: - Tab Content
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -153,7 +152,9 @@ struct PhoneTabView: View {
|
|||||||
// To restore: uncomment block below and remove ZStack
|
// To restore: uncomment block below and remove ZStack
|
||||||
ZStack {
|
ZStack {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||||
|
.toolbar(.hidden, for: .tabBar)
|
||||||
|
.navigationBarBackButtonHidden(false)
|
||||||
} label: {
|
} label: {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
@ -243,39 +244,19 @@ struct PhoneTabView: View {
|
|||||||
EmptyView() // search is directly implemented
|
EmptyView() // search is directly implemented
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView()
|
SettingsView()
|
||||||
|
.toolbar(.hidden, for: .tabBar)
|
||||||
case .article:
|
case .article:
|
||||||
BookmarksView(state: .all, type: [.article], selectedBookmark: .constant(nil))
|
BookmarksView(state: .all, type: [.article], selectedBookmark: .constant(nil))
|
||||||
|
.toolbar(.hidden, for: .tabBar)
|
||||||
case .videos:
|
case .videos:
|
||||||
BookmarksView(state: .all, type: [.video], selectedBookmark: .constant(nil))
|
BookmarksView(state: .all, type: [.video], selectedBookmark: .constant(nil))
|
||||||
|
.toolbar(.hidden, for: .tabBar)
|
||||||
case .pictures:
|
case .pictures:
|
||||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
||||||
|
.toolbar(.hidden, for: .tabBar)
|
||||||
case .tags:
|
case .tags:
|
||||||
LabelsView(selectedTag: .constant(nil))
|
LabelsView(selectedTag: .constant(nil))
|
||||||
}
|
.toolbar(.hidden, for: .tabBar)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - View Extension for iOS 26+ Compatibility
|
|
||||||
extension View {
|
|
||||||
@ViewBuilder
|
|
||||||
func searchToolbarBehaviorIfAvailable() -> some View {
|
|
||||||
if #available(iOS 26, *) {
|
|
||||||
self
|
|
||||||
.searchToolbarBehavior(.minimize)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func tabBarMinimizeBehaviorIfAvailable() -> some View {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
self
|
|
||||||
.tabBarMinimizeBehavior(.onScrollDown)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user