diff --git a/Marks/Views/BookmarkRow.swift b/Marks/Views/BookmarkRow.swift index 330922d..ef450d0 100644 --- a/Marks/Views/BookmarkRow.swift +++ b/Marks/Views/BookmarkRow.swift @@ -2,6 +2,7 @@ import SwiftUI struct BookmarkRow: View { let bookmark: Bookmark + var readingProgress: Double = 0 var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -54,6 +55,22 @@ struct BookmarkRow: View { } .padding(.leading, 40) } + + // Reading progress indicator + if readingProgress > 0.02 { + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle() + .fill(Color(.systemGray5)) + Rectangle() + .fill(Color.blue.opacity(0.55)) + .frame(width: geo.size.width * min(readingProgress, 1)) + } + } + .frame(height: 2) + .clipShape(Capsule()) + .padding(.leading, 40) + } } .padding(.vertical, 14) .contentShape(Rectangle()) diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 33892fc..5f8e13f 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -11,12 +11,13 @@ struct BookmarksView: View { @State private var editingBookmark: Bookmark? @State private var browsingBookmark: Bookmark? @State private var podcastBookmark: Bookmark? + @State private var readingProgress: [String: Double] = ReadingProgress.all() var body: some View { NavigationStack { List { ForEach(viewModel.bookmarks) { bookmark in - BookmarkRow(bookmark: bookmark) + BookmarkRow(bookmark: bookmark, readingProgress: readingProgress[bookmark.url] ?? 0) .onTapGesture { browsingBookmark = bookmark } @@ -158,6 +159,9 @@ struct BookmarksView: View { BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude) } } + .onChange(of: browsingBookmark) { _, new in + if new == nil { readingProgress = ReadingProgress.all() } + } .sheet(item: $podcastBookmark) { bookmark in if let claude = viewModel.claude { PodcastPlayerView(articleUrl: bookmark.url, claude: claude) diff --git a/Marks/Views/BrowserView.swift b/Marks/Views/BrowserView.swift index 0e3754d..e57959e 100644 --- a/Marks/Views/BrowserView.swift +++ b/Marks/Views/BrowserView.swift @@ -1,6 +1,32 @@ import SwiftUI import WebKit +// MARK: - Reading progress persistence + +enum ReadingProgress { + private static let key = "readingProgress" + + static func get(url: String) -> Double { + (UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0 + } + + static func set(url: String, progress: Double) { + var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:] + if progress >= 0.99 { + store.removeValue(forKey: url) // fully read — clean up + } else { + store[url] = progress + } + UserDefaults.standard.set(store, forKey: key) + } + + static func all() -> [String: Double] { + (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:] + } +} + +// MARK: - BrowserState + @Observable final class BrowserState: NSObject, WKNavigationDelegate { let webView: WKWebView @@ -9,16 +35,34 @@ final class BrowserState: NSObject, WKNavigationDelegate { var canGoForward = false var pageTitle = "" var currentURL: URL? + var readingProgress: Double = 0 + + // Weak wrapper breaks WKUserContentController's strong retain of the handler + private final class ScrollHandler: NSObject, WKScriptMessageHandler { + weak var state: BrowserState? + init(_ state: BrowserState) { self.state = state } + func userContentController(_ c: WKUserContentController, didReceive msg: WKScriptMessage) { + if let p = msg.body as? Double { state?.readingProgress = p } + } + } + private var scrollHandler: ScrollHandler? init(url: URL) { let config = WKWebViewConfiguration() config.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: config) super.init() + let handler = ScrollHandler(self) + scrollHandler = handler + webView.configuration.userContentController.add(handler, name: "scrollProgress") webView.navigationDelegate = self webView.load(URLRequest(url: url)) } + deinit { + webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress") + } + func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { isLoading = true } @@ -35,24 +79,46 @@ final class BrowserState: NSObject, WKNavigationDelegate { canGoForward = webView.canGoForward pageTitle = webView.title ?? "" currentURL = webView.url + + // Restore persisted progress, then start tracking scroll + if let urlStr = webView.url?.absoluteString { + let saved = ReadingProgress.get(url: urlStr) + if saved > 0 { + let pct = saved * 100 + webView.evaluateJavaScript("window.scrollTo(0, (document.body.scrollHeight - window.innerHeight) * \(pct) / 100)", completionHandler: nil) + } + } + webView.evaluateJavaScript(scrollTrackingJS, completionHandler: nil) } - func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { - isLoading = false - } - - func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { - isLoading = false - } + func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { isLoading = false } + func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { isLoading = false } func enterReaderMode(completion: @escaping () -> Void) { webView.evaluateJavaScript(readerModeJS) { _, _ in - DispatchQueue.main.async { completion() } + DispatchQueue.main.async { + completion() + self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil) + } } } - func exitReaderMode() { - webView.reload() + func exitReaderMode() { webView.reload() } + + private var scrollTrackingJS: String { + """ + (function(){ + function report(){ + var h=document.body.scrollHeight-window.innerHeight; + var p=h>0?window.scrollY/h:0; + window.webkit.messageHandlers.scrollProgress.postMessage(Math.min(1,Math.max(0,p))); + } + window.removeEventListener('scroll',window._marksScrollFn); + window._marksScrollFn=report; + window.addEventListener('scroll',report,{passive:true}); + report(); + })(); + """ } private var readerModeJS: String { @@ -106,12 +172,16 @@ final class BrowserState: NSObject, WKNavigationDelegate { } } +// MARK: - WebView representable + private struct WebViewRepresentable: UIViewRepresentable { let webView: WKWebView func makeUIView(context: Context) -> WKWebView { webView } func updateUIView(_ uiView: WKWebView, context: Context) {} } +// MARK: - BrowserView + struct BrowserView: View { let initialURL: URL let initialTitle: String @@ -136,6 +206,17 @@ struct BrowserView: View { WebViewRepresentable(webView: state.webView) .ignoresSafeArea(edges: .bottom) + // Reading progress bar + if state.readingProgress > 0.01 && state.readingProgress < 0.99 { + GeometryReader { geo in + Rectangle() + .fill(Color.blue.opacity(0.7)) + .frame(width: geo.size.width * state.readingProgress, height: 3) + } + .frame(height: 3) + .animation(.linear(duration: 0.1), value: state.readingProgress) + } + if state.isLoading { ProgressView() .padding(.top, 8) @@ -151,25 +232,19 @@ struct BrowserView: View { } } ToolbarItem(placement: .topBarTrailing) { - Button { - openURL(state.currentURL ?? initialURL) - } label: { + Button { openURL(state.currentURL ?? initialURL) } label: { Image(systemName: "safari") } } ToolbarItemGroup(placement: .bottomBar) { - Button { - state.webView.goBack() - } label: { + Button { state.webView.goBack() } label: { Image(systemName: "chevron.left") } .disabled(!state.canGoBack) Spacer() - Button { - state.webView.goForward() - } label: { + Button { state.webView.goForward() } label: { Image(systemName: "chevron.right") } .disabled(!state.canGoForward) @@ -177,12 +252,8 @@ struct BrowserView: View { Spacer() Button { - if readerMode { - state.exitReaderMode() - readerMode = false - } else { - state.enterReaderMode { readerMode = true } - } + if readerMode { state.exitReaderMode(); readerMode = false } + else { state.enterReaderMode { readerMode = true } } } label: { Image(systemName: readerMode ? "doc.text.fill" : "doc.text") } @@ -191,13 +262,10 @@ struct BrowserView: View { Spacer() if claude != nil { - Button { - showPodcast = true - } label: { + Button { showPodcast = true } label: { Image(systemName: "headphones") } .disabled(state.isLoading) - Spacer() } @@ -207,10 +275,14 @@ struct BrowserView: View { } } } + .onDisappear { + let urlStr = (state.currentURL ?? initialURL).absoluteString + let p = state.readingProgress + if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) } + } .sheet(isPresented: $showPodcast) { if let claude { - let url = (state.currentURL ?? initialURL).absoluteString - PodcastPlayerView(articleUrl: url, claude: claude) + PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude) } } }