Apply WWDC26 design-brief fixes to the bookmark list reading surface: - Dynamic Type: replace fixed .system(size:) points with text styles (title .headline, domain/date .footnote, summary .subheadline, tags .caption, podcast glyph .title3). Rows now scale and wrap with the user's text-size setting instead of staying fixed. - Perf: podcastCached was a FileManager.fileExists stat evaluated inside body — i.e. per row, per scroll frame. Move it to @State populated by a .task(id:) that stats off the main actor, once per appearance. - Forgiveness: destructive trailing swipe is now allowsFullSwipe: false so a single fling can't permanently delete; requires tapping the button. Adds docs/design-wwdc26.md (consolidated brief + codebase audit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
113 lines
3.9 KiB
Swift
113 lines
3.9 KiB
Swift
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 {
|
|
BookmarkRow(
|
|
bookmark: bookmark,
|
|
readingProgress: readingProgress,
|
|
onPodcast: handlePodcast
|
|
)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { onOpen() }
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
|
.listRowSeparator(.visible)
|
|
// Delete is destructive and not undoable — require an explicit tap on the
|
|
// revealed button rather than letting a single full swipe delete instantly.
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
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
|
|
}
|
|
}
|
|
}
|