Add reading progress tracking
All checks were successful
CI / build-and-deploy (push) Successful in 25s
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:
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user