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:
@@ -28,5 +28,14 @@
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>marks</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -18,16 +18,24 @@ struct MarksApp: App {
|
||||
defaultConfig.save()
|
||||
serverConfig = defaultConfig
|
||||
}
|
||||
.task { serverConfig.save() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IdentifiableURL: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
// Holds the API + ViewModel so they survive re-renders
|
||||
struct MainContainer: View {
|
||||
let config: ServerConfig
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@State private var viewModel: BookmarksViewModel
|
||||
@State private var deepLinkBrowser: IdentifiableURL?
|
||||
@State private var showDeepLinkPlayer = false
|
||||
|
||||
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||
self.config = config
|
||||
@@ -48,5 +56,45 @@ struct MainContainer: View {
|
||||
SearchView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleDeepLink(url)
|
||||
}
|
||||
.sheet(item: $deepLinkBrowser) { item in
|
||||
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer)
|
||||
}
|
||||
.sheet(isPresented: $showDeepLinkPlayer) {
|
||||
PodcastPlayerView(
|
||||
vm: viewModel.podcastPlayer,
|
||||
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
||||
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
||||
claude: viewModel.claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
guard url.scheme == "marks",
|
||||
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value
|
||||
else { return }
|
||||
|
||||
switch url.host {
|
||||
case "bookmark":
|
||||
if let browserURL = URL(string: articleUrl) {
|
||||
deepLinkBrowser = IdentifiableURL(url: browserURL)
|
||||
}
|
||||
case "podcast":
|
||||
let podcasts = WidgetDataStore.loadPodcasts()
|
||||
let found = podcasts.first { $0.articleUrl == articleUrl }
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: articleUrl,
|
||||
articleTitle: found?.title ?? "",
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showDeepLinkPlayer = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +67,13 @@ struct ClaudeService: Sendable {
|
||||
return try JSONDecoder().decode(PodcastStatus.self, from: data)
|
||||
}
|
||||
|
||||
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil) async throws -> URL {
|
||||
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil, parentBookmarkUrl: String? = nil) async throws -> URL {
|
||||
let dest = ClaudeService.cachedPodcastURL(for: articleUrl)
|
||||
try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try await get("/v1/podcast/audio/\(jobId)")
|
||||
try data.write(to: dest, options: .atomic)
|
||||
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title)
|
||||
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
return dest
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ struct PodcastEntry: Codable, Identifiable {
|
||||
let filename: String
|
||||
var title: String?
|
||||
let createdAt: Date
|
||||
var parentBookmarkUrl: String?
|
||||
}
|
||||
|
||||
enum PodcastIndex {
|
||||
@@ -35,14 +36,19 @@ enum PodcastIndex {
|
||||
return entries.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
static func upsert(articleUrl: String, filename: String, title: String?) {
|
||||
static func upsert(articleUrl: String, filename: String, title: String?, parentBookmarkUrl: String? = nil) {
|
||||
var entries = all()
|
||||
entries.removeAll { $0.articleUrl == articleUrl }
|
||||
entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename,
|
||||
title: title, createdAt: Date()), at: 0)
|
||||
title: title, createdAt: Date(),
|
||||
parentBookmarkUrl: parentBookmarkUrl), at: 0)
|
||||
save(entries)
|
||||
}
|
||||
|
||||
static func find(for bookmarkUrl: String) -> [PodcastEntry] {
|
||||
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
|
||||
}
|
||||
|
||||
static func remove(articleUrl: String) {
|
||||
var entries = all()
|
||||
entries.removeAll { $0.articleUrl == articleUrl }
|
||||
@@ -52,5 +58,8 @@ enum PodcastIndex {
|
||||
private static func save(_ entries: [PodcastEntry]) {
|
||||
guard let data = try? encoder.encode(entries) else { return }
|
||||
try? data.write(to: indexURL, options: .atomic)
|
||||
WidgetDataStore.savePodcasts(entries.prefix(10).map {
|
||||
WidgetPodcast(articleUrl: $0.articleUrl, title: $0.title ?? $0.articleUrl, createdAt: $0.createdAt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
111
Marks/Views/BookmarkListRow.swift
Normal file
111
Marks/Views/BookmarkListRow.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
|
||||
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
|
||||
struct BookmarkListRow: View {
|
||||
let bookmark: Bookmark
|
||||
let viewModel: BookmarksViewModel
|
||||
var readingProgress: Double = 0
|
||||
let onOpen: () -> Void
|
||||
var onEdit: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var showFullPlayer = false
|
||||
@State private var episodePickerBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
Button { onOpen() } label: {
|
||||
BookmarkRow(
|
||||
bookmark: bookmark,
|
||||
readingProgress: readingProgress,
|
||||
onPodcast: handlePodcast
|
||||
)
|
||||
}
|
||||
.buttonStyle(RowPressStyle())
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
if let onEdit {
|
||||
Button { onEdit() } label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if let onEdit {
|
||||
Button { onEdit() } label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
Button { onOpen() } label: {
|
||||
Label("Open", systemImage: "globe")
|
||||
}
|
||||
Button {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
} label: {
|
||||
Label("Open in Safari", systemImage: "safari")
|
||||
}
|
||||
Button { handlePodcast() } label: {
|
||||
Label("Convert to Podcast", systemImage: "headphones")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFullPlayer) {
|
||||
PodcastPlayerView(
|
||||
vm: viewModel.podcastPlayer,
|
||||
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
||||
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
||||
claude: viewModel.claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
.sheet(item: $episodePickerBookmark) { b in
|
||||
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePodcast() {
|
||||
let episodes = PodcastIndex.find(for: bookmark.url)
|
||||
if episodes.count >= 2 {
|
||||
episodePickerBookmark = bookmark
|
||||
} else if let ep = episodes.first {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: ep.articleUrl,
|
||||
articleTitle: ep.title ?? bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
} else {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: bookmark.url,
|
||||
articleTitle: bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,57 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Skeleton loading row
|
||||
|
||||
private struct SkeletonRow: View {
|
||||
var delay: Double = 0
|
||||
@State private var dimmed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 32, height: 32)
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Capsule()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 13)
|
||||
Capsule()
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(width: 140, height: 10)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.opacity(dimmed ? 0.45 : 1)
|
||||
.onAppear {
|
||||
withAnimation(
|
||||
.easeInOut(duration: 0.9)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(delay)
|
||||
) { dimmed = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row press ButtonStyle
|
||||
|
||||
struct RowPressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.opacity(configuration.isPressed ? 0.75 : 1)
|
||||
.animation(.spring(duration: 0.18, bounce: 0), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BookmarksView
|
||||
|
||||
struct BookmarksView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var showSettings = false
|
||||
@State private var showCollections = false
|
||||
@State private var showAddBookmark = false
|
||||
@@ -17,84 +64,23 @@ struct BookmarksView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks) { bookmark in
|
||||
BookmarkRow(
|
||||
bookmark: bookmark,
|
||||
readingProgress: readingProgress[bookmark.url] ?? 0,
|
||||
onPodcast: {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: bookmark.url,
|
||||
articleTitle: bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
browsingBookmark = bookmark
|
||||
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
SkeletonRow(delay: Double(i) * 0.13)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
.listRowSeparator(.visible)
|
||||
}
|
||||
} else {
|
||||
ForEach(viewModel.bookmarks) { bookmark in
|
||||
BookmarkListRow(
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
readingProgress: readingProgress[bookmark.url] ?? 0,
|
||||
onOpen: { browsingBookmark = bookmark },
|
||||
onEdit: { editingBookmark = bookmark }
|
||||
)
|
||||
.onAppear { maybeLoadMore(bookmark) }
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
Button {
|
||||
editingBookmark = bookmark
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingBookmark = bookmark
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Button {
|
||||
browsingBookmark = bookmark
|
||||
} label: {
|
||||
Label("Open", systemImage: "globe")
|
||||
}
|
||||
Button {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
} label: {
|
||||
Label("Open in Safari", systemImage: "safari")
|
||||
}
|
||||
Button {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: bookmark.url,
|
||||
articleTitle: bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
} label: {
|
||||
Label("Convert to Podcast", systemImage: "headphones")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
@@ -103,6 +89,7 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
|
||||
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
@@ -139,6 +126,7 @@ struct BookmarksView: View {
|
||||
Image(systemName: viewModel.unreadFilter
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
Button { showSettings = true } label: {
|
||||
Image(systemName: "gearshape")
|
||||
@@ -147,11 +135,13 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ProgressView()
|
||||
}
|
||||
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here."))
|
||||
ContentUnavailableView(
|
||||
"No Bookmarks",
|
||||
systemImage: "bookmark",
|
||||
description: Text("Bookmarks you save will appear here.")
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
@@ -169,6 +159,7 @@ struct BookmarksView: View {
|
||||
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||
@@ -184,7 +175,9 @@ struct BookmarksView: View {
|
||||
}
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude)
|
||||
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: browsingBookmark) { _, new in
|
||||
|
||||
@@ -39,6 +39,9 @@ final class BookmarksViewModel {
|
||||
nextPageUrl = response.next
|
||||
restoreAIData()
|
||||
saveToCache(bookmarks)
|
||||
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
|
||||
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
|
||||
})
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ final class PodcastPlayerViewModel {
|
||||
private var interruptionObserver: Any?
|
||||
private var currentPodcastTitle: String = "Marks Podcast"
|
||||
|
||||
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) {
|
||||
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) {
|
||||
// Idempotent — don't restart if already generating or playing this article
|
||||
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
|
||||
|
||||
@@ -90,7 +90,7 @@ final class PodcastPlayerViewModel {
|
||||
)
|
||||
if job.isDone {
|
||||
phase = .downloading
|
||||
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title)
|
||||
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
|
||||
return
|
||||
}
|
||||
@@ -674,3 +674,72 @@ struct PodcastLibraryView: View {
|
||||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Episode picker (multiple episodes per bookmark)
|
||||
|
||||
struct EpisodePickerView: View {
|
||||
let bookmark: Bookmark
|
||||
let vm: PodcastPlayerViewModel
|
||||
let claude: ClaudeService
|
||||
|
||||
@State private var entries: [PodcastEntry] = []
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(entries) { entry in
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.lineLimit(2)
|
||||
if entry.articleUrl != bookmark.url {
|
||||
Text(entry.articleUrl)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
vm.start(articleUrl: entry.articleUrl,
|
||||
articleTitle: entry.title ?? bookmark.displayTitle,
|
||||
claude: claude)
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) { deleteEntry(entry) } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Episodes")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { entries = PodcastIndex.find(for: bookmark.url) }
|
||||
}
|
||||
|
||||
private func deleteEntry(_ entry: PodcastEntry) {
|
||||
PodcastIndex.remove(articleUrl: entry.articleUrl)
|
||||
entries.removeAll { $0.articleUrl == entry.articleUrl }
|
||||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,30 +2,22 @@ import SwiftUI
|
||||
|
||||
struct SearchView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var searchText = ""
|
||||
@State private var useSemanticSearch = false
|
||||
@State private var semanticResults: [Bookmark]? = nil
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(results) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
BookmarkListRow(
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
onOpen: { browsingBookmark = bookmark }
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -49,6 +41,19 @@ struct SearchView: View {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
|
||||
.onChange(of: searchText) { _, _ in scheduleSearch() }
|
||||
|
||||
@@ -48,32 +48,17 @@ struct TagsView: View {
|
||||
struct TagBookmarksView: View {
|
||||
let tag: String
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(filteredBookmarks) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
BookmarkListRow(
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
onOpen: { browsingBookmark = bookmark }
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -84,6 +69,19 @@ struct TagBookmarksView: View {
|
||||
ContentUnavailableView("No Bookmarks", systemImage: "tag")
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredBookmarks: [Bookmark] {
|
||||
|
||||
Reference in New Issue
Block a user