All checks were successful
CI / build-and-deploy (push) Successful in 15s
Surface every podcast in PodcastIndex as a top-level tab instead of a buried sheet. Repurpose PodcastLibraryView into the tab: play/pause toggle per episode, a now-playing bar that opens the full player, swipe/Edit to delete. Uses the shared podcastPlayer so playback state is consistent across tabs. Removes the redundant Podcasts entry from the bookmarks menu. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
239 lines
9.1 KiB
Swift
239 lines
9.1 KiB
Swift
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
|
|
|
|
@State private var showSettings = false
|
|
@State private var showCollections = false
|
|
@State private var showAddBookmark = false
|
|
@State private var editingBookmark: Bookmark?
|
|
@State private var browsingBookmark: Bookmark?
|
|
@State private var showFullPlayer = false
|
|
@State private var showAsk = false
|
|
@State private var readingProgress: [String: Double] = ReadingProgress.all()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
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) }
|
|
}
|
|
}
|
|
|
|
if viewModel.isLoadingMore {
|
|
HStack { Spacer(); ProgressView(); Spacer() }
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
|
|
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Menu {
|
|
Button {
|
|
showAsk = true
|
|
} label: {
|
|
Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right")
|
|
}
|
|
Button {
|
|
Task { await viewModel.generateSmartCollections() }
|
|
showCollections = true
|
|
} label: {
|
|
Label("Smart Collections", systemImage: "sparkles")
|
|
}
|
|
Button {
|
|
Task { await viewModel.enrichAll() }
|
|
} label: {
|
|
Label("Enrich All with AI", systemImage: "wand.and.stars")
|
|
}
|
|
} label: {
|
|
Image(systemName: "sparkles")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
HStack(spacing: 16) {
|
|
Button { showAddBookmark = true } label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
Button {
|
|
Task { await viewModel.toggleUnreadFilter() }
|
|
} label: {
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Bookmarks",
|
|
systemImage: "bookmark",
|
|
description: Text("Bookmarks you save will appear here.")
|
|
)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
VStack(spacing: 8) {
|
|
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
|
|
MiniPlayerView(vm: viewModel.podcastPlayer) {
|
|
showFullPlayer = true
|
|
}
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
if viewModel.enrichmentProgress > 0 {
|
|
enrichmentBanner
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
.sheet(isPresented: $showCollections) {
|
|
CollectionsView(viewModel: viewModel)
|
|
}
|
|
.sheet(isPresented: $showAsk) {
|
|
AskView()
|
|
}
|
|
.sheet(isPresented: $showAddBookmark) {
|
|
AddBookmarkView(viewModel: viewModel)
|
|
}
|
|
.sheet(item: $editingBookmark) { bookmark in
|
|
EditBookmarkView(viewModel: viewModel, bookmark: bookmark)
|
|
}
|
|
.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)
|
|
}
|
|
.bookmarkOnscreen(bookmark)
|
|
}
|
|
}
|
|
.onChange(of: browsingBookmark) { _, new in
|
|
if new == nil { readingProgress = ReadingProgress.all() }
|
|
}
|
|
.sheet(isPresented: $showFullPlayer) {
|
|
PodcastPlayerView(
|
|
vm: viewModel.podcastPlayer,
|
|
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
|
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
|
claude: viewModel.claude,
|
|
stopOnDismiss: false
|
|
)
|
|
}
|
|
.refreshable {
|
|
await viewModel.load()
|
|
}
|
|
.task {
|
|
if viewModel.bookmarks.isEmpty {
|
|
await viewModel.load()
|
|
}
|
|
}
|
|
.alert("Error", isPresented: Binding(
|
|
get: { viewModel.error != nil },
|
|
set: { if !$0 { viewModel.error = nil } }
|
|
)) {
|
|
Button("OK") { viewModel.error = nil }
|
|
} message: {
|
|
Text(viewModel.error ?? "")
|
|
}
|
|
}
|
|
|
|
private var enrichmentBanner: some View {
|
|
HStack(spacing: 10) {
|
|
ProgressView(value: viewModel.enrichmentProgress)
|
|
.progressViewStyle(.linear)
|
|
.tint(.primary)
|
|
Text("Adding AI summaries…")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 12)
|
|
.background(.regularMaterial)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
private func maybeLoadMore(_ bookmark: Bookmark) {
|
|
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
|
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
|
Task { await viewModel.loadMore() }
|
|
}
|
|
}
|