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 showPodcastLibrary = 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") } Button { showPodcastLibrary = true } label: { Label("Podcasts", systemImage: "headphones") } } 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 ) } .sheet(isPresented: $showPodcastLibrary) { PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude) } .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() } } }