Add reading progress tracking
All checks were successful
CI / build-and-deploy (push) Successful in 25s

- BrowserState: inject scroll-tracking JS after page load, report position
  via WKScriptMessageHandler (weak ref to avoid retain cycle)
- BrowserView: thin blue progress bar at top of viewport; saves progress
  to UserDefaults on dismiss; restores scroll position on revisit
- BookmarkRow: 2px progress bar below tags for partially-read articles
- BookmarksView: loads ReadingProgress.all() on appear, refreshes when
  browser sheet dismisses
- ReadingProgress: static helper for UserDefaults-backed [url: Double] store;
  removes entry when article is fully read (≥99%)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-05-21 18:42:12 -05:00
parent bc16ee986d
commit db5092ce02
3 changed files with 125 additions and 32 deletions

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct BookmarkRow: View { struct BookmarkRow: View {
let bookmark: Bookmark let bookmark: Bookmark
var readingProgress: Double = 0
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -54,6 +55,22 @@ struct BookmarkRow: View {
} }
.padding(.leading, 40) .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) .padding(.vertical, 14)
.contentShape(Rectangle()) .contentShape(Rectangle())

View File

@@ -11,12 +11,13 @@ struct BookmarksView: View {
@State private var editingBookmark: Bookmark? @State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark? @State private var browsingBookmark: Bookmark?
@State private var podcastBookmark: Bookmark? @State private var podcastBookmark: Bookmark?
@State private var readingProgress: [String: Double] = ReadingProgress.all()
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
ForEach(viewModel.bookmarks) { bookmark in ForEach(viewModel.bookmarks) { bookmark in
BookmarkRow(bookmark: bookmark) BookmarkRow(bookmark: bookmark, readingProgress: readingProgress[bookmark.url] ?? 0)
.onTapGesture { .onTapGesture {
browsingBookmark = bookmark browsingBookmark = bookmark
} }
@@ -158,6 +159,9 @@ struct BookmarksView: View {
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude) 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 .sheet(item: $podcastBookmark) { bookmark in
if let claude = viewModel.claude { if let claude = viewModel.claude {
PodcastPlayerView(articleUrl: bookmark.url, claude: claude) PodcastPlayerView(articleUrl: bookmark.url, claude: claude)

View File

@@ -1,6 +1,32 @@
import SwiftUI import SwiftUI
import WebKit 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 @Observable
final class BrowserState: NSObject, WKNavigationDelegate { final class BrowserState: NSObject, WKNavigationDelegate {
let webView: WKWebView let webView: WKWebView
@@ -9,16 +35,34 @@ final class BrowserState: NSObject, WKNavigationDelegate {
var canGoForward = false var canGoForward = false
var pageTitle = "" var pageTitle = ""
var currentURL: URL? 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) { init(url: URL) {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true config.allowsInlineMediaPlayback = true
webView = WKWebView(frame: .zero, configuration: config) webView = WKWebView(frame: .zero, configuration: config)
super.init() super.init()
let handler = ScrollHandler(self)
scrollHandler = handler
webView.configuration.userContentController.add(handler, name: "scrollProgress")
webView.navigationDelegate = self webView.navigationDelegate = self
webView.load(URLRequest(url: url)) webView.load(URLRequest(url: url))
} }
deinit {
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) {
isLoading = true isLoading = true
} }
@@ -35,24 +79,46 @@ final class BrowserState: NSObject, WKNavigationDelegate {
canGoForward = webView.canGoForward canGoForward = webView.canGoForward
pageTitle = webView.title ?? "" pageTitle = webView.title ?? ""
currentURL = webView.url 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) { func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { isLoading = false }
isLoading = false func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { isLoading = false }
}
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) {
isLoading = false
}
func enterReaderMode(completion: @escaping () -> Void) { func enterReaderMode(completion: @escaping () -> Void) {
webView.evaluateJavaScript(readerModeJS) { _, _ in webView.evaluateJavaScript(readerModeJS) { _, _ in
DispatchQueue.main.async { completion() } DispatchQueue.main.async {
completion()
self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil)
}
} }
} }
func exitReaderMode() { func exitReaderMode() { webView.reload() }
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 { private var readerModeJS: String {
@@ -106,12 +172,16 @@ final class BrowserState: NSObject, WKNavigationDelegate {
} }
} }
// MARK: - WebView representable
private struct WebViewRepresentable: UIViewRepresentable { private struct WebViewRepresentable: UIViewRepresentable {
let webView: WKWebView let webView: WKWebView
func makeUIView(context: Context) -> WKWebView { webView } func makeUIView(context: Context) -> WKWebView { webView }
func updateUIView(_ uiView: WKWebView, context: Context) {} func updateUIView(_ uiView: WKWebView, context: Context) {}
} }
// MARK: - BrowserView
struct BrowserView: View { struct BrowserView: View {
let initialURL: URL let initialURL: URL
let initialTitle: String let initialTitle: String
@@ -136,6 +206,17 @@ struct BrowserView: View {
WebViewRepresentable(webView: state.webView) WebViewRepresentable(webView: state.webView)
.ignoresSafeArea(edges: .bottom) .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 { if state.isLoading {
ProgressView() ProgressView()
.padding(.top, 8) .padding(.top, 8)
@@ -151,25 +232,19 @@ struct BrowserView: View {
} }
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Button { openURL(state.currentURL ?? initialURL) } label: {
openURL(state.currentURL ?? initialURL)
} label: {
Image(systemName: "safari") Image(systemName: "safari")
} }
} }
ToolbarItemGroup(placement: .bottomBar) { ToolbarItemGroup(placement: .bottomBar) {
Button { Button { state.webView.goBack() } label: {
state.webView.goBack()
} label: {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
} }
.disabled(!state.canGoBack) .disabled(!state.canGoBack)
Spacer() Spacer()
Button { Button { state.webView.goForward() } label: {
state.webView.goForward()
} label: {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
} }
.disabled(!state.canGoForward) .disabled(!state.canGoForward)
@@ -177,12 +252,8 @@ struct BrowserView: View {
Spacer() Spacer()
Button { Button {
if readerMode { if readerMode { state.exitReaderMode(); readerMode = false }
state.exitReaderMode() else { state.enterReaderMode { readerMode = true } }
readerMode = false
} else {
state.enterReaderMode { readerMode = true }
}
} label: { } label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text") Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
} }
@@ -191,13 +262,10 @@ struct BrowserView: View {
Spacer() Spacer()
if claude != nil { if claude != nil {
Button { Button { showPodcast = true } label: {
showPodcast = true
} label: {
Image(systemName: "headphones") Image(systemName: "headphones")
} }
.disabled(state.isLoading) .disabled(state.isLoading)
Spacer() 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) { .sheet(isPresented: $showPodcast) {
if let claude { if let claude {
let url = (state.currentURL ?? initialURL).absoluteString PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude)
PodcastPlayerView(articleUrl: url, claude: claude)
} }
} }
} }