Fix ShareExtension "Open Marks first" + add widget + BookmarkListRow polish
All checks were successful
CI / build-and-deploy (push) Successful in 41s
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:
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user