Fix ShareExtension "Open Marks first" + add widget + BookmarkListRow polish
All checks were successful
CI / build-and-deploy (push) Successful in 41s

- Save serverConfig to App Group on every app launch so ShareExtension
  can always find credentials without requiring a disconnect/reconnect flow
- Add MarksWidget target with RandomBookmarkWidget (random saved bookmark,
  reloads hourly) using file-based App Group storage to avoid CFPreferences issues
- Extract BookmarkListRow as shared component with editorial typography tweaks
  (17pt semibold title, 13pt domain, relative date, 32×32 favicon, italic AI summary,
  20pt podcast icon, 16pt horizontal insets)
- Add MarksWidget and ShareExtension Xcode schemes for direct run/debug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-05-24 13:11:05 -05:00
parent d87500a7dc
commit 199fc2d043
23 changed files with 1437 additions and 172 deletions

View File

@@ -25,6 +25,29 @@ enum ReadingProgress {
}
}
// MARK: - Indeterminate page load bar
private struct PageLoadBar: View {
@State private var phase: CGFloat = 0
var body: some View {
GeometryReader { geo in
let barWidth = geo.size.width * 0.38
Capsule()
.fill(.blue.opacity(0.75))
.frame(width: barWidth, height: 3)
.offset(x: (phase * (geo.size.width + barWidth)) - barWidth)
}
.frame(height: 3)
.clipped()
.onAppear {
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
// MARK: - BrowserState
@Observable
@@ -196,18 +219,25 @@ struct BrowserView: View {
let initialURL: URL
let initialTitle: String
let claude: ClaudeService
let podcastPlayer: PodcastPlayerViewModel
var onArchive: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var state: BrowserState
@State private var readerMode = false
@State private var showPodcast = false
@State private var podcastVM = PodcastPlayerViewModel()
@State private var archiveTapCount = 0
@State private var navBackCount = 0
@State private var navForwardCount = 0
@State private var toolbarHidden = false
init(url: URL, title: String, claude: ClaudeService) {
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) {
self.initialURL = url
self.initialTitle = title
self.claude = claude
self.podcastPlayer = podcastPlayer
self.onArchive = onArchive
self._state = State(initialValue: BrowserState(url: url))
}
@@ -221,16 +251,17 @@ struct BrowserView: View {
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
GeometryReader { geo in
Rectangle()
.fill(Color.blue.opacity(0.7))
.fill(Color.blue.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3)
}
.frame(height: 3)
.animation(.linear(duration: 0.1), value: state.readingProgress)
}
// Page loading sweep bar (replaces center spinner)
if state.isLoading {
ProgressView()
.padding(.top, 8)
PageLoadBar()
.transition(.opacity)
}
}
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
@@ -247,44 +278,28 @@ struct BrowserView: View {
Image(systemName: "safari")
}
}
ToolbarItemGroup(placement: .bottomBar) {
Button { state.webView.goBack() } label: {
Image(systemName: "chevron.left")
}
.disabled(!state.canGoBack)
Spacer()
Button { state.webView.goForward() } label: {
Image(systemName: "chevron.right")
}
.disabled(!state.canGoForward)
Spacer()
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
}
.disabled(state.isLoading)
Spacer()
Button { showPodcast = true } label: {
Image(systemName: "headphones")
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
}
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomBar
.offset(y: toolbarHidden ? 88 : 0)
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
}
.onChange(of: state.readingProgress) { old, new in
let delta = new - old
withAnimation(.spring(duration: 0.3, bounce: 0)) {
if delta > 0.012 && new > 0.07 {
toolbarHidden = true
} else if delta < -0.005 || new < 0.05 {
toolbarHidden = false
}
}
}
.onChange(of: state.isLoading) { _, loading in
if loading {
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
}
}
.onDisappear {
let urlStr = (state.currentURL ?? initialURL).absoluteString
let p = state.readingProgress
@@ -292,12 +307,101 @@ struct BrowserView: View {
}
.sheet(isPresented: $showPodcast) {
PodcastPlayerView(
vm: podcastVM,
vm: podcastPlayer,
articleUrl: (state.currentURL ?? initialURL).absoluteString,
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
claude: claude,
stopOnDismiss: true
stopOnDismiss: false
)
}
}
private var bottomBar: some View {
HStack {
Button {
navBackCount += 1
state.webView.goBack()
} label: {
Image(systemName: "chevron.left")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoBack)
.sensoryFeedback(.impact(weight: .light), trigger: navBackCount)
Spacer()
Button {
navForwardCount += 1
state.webView.goForward()
} label: {
Image(systemName: "chevron.right")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoForward)
.sensoryFeedback(.impact(weight: .light), trigger: navForwardCount)
Spacer()
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
.contentTransition(.symbolEffect(.replace))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
if onArchive != nil { Spacer() }
if let onArchive {
Button {
archiveTapCount += 1
Task { await onArchive() }
dismiss()
} label: {
Image(systemName: "archivebox")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount)
}
Spacer()
Button {
let currentUrl = (state.currentURL ?? initialURL).absoluteString
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
podcastPlayer.start(
articleUrl: currentUrl,
articleTitle: currentTitle,
claude: claude,
parentBookmarkUrl: initialURL.absoluteString
)
showPodcast = true
} label: {
Image(systemName: "headphones")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
.font(.system(size: 17))
.foregroundStyle(.primary)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.bar)
.overlay(alignment: .top) { Divider() }
}
}