import SwiftUI // MARK: - LibraryView // 2-column grid of saved books with progress overlay, genre/sort/reading-status filters. struct LibraryView: View { @State private var viewModel = LibraryViewModel() @EnvironmentObject private var networkMonitor: NetworkMonitor // Sort sheet @State private var showingSortSheet = false var body: some View { NavigationStack { VStack(spacing: 0) { OfflineBanner() // Filter bar filterBar if viewModel.isLoading && viewModel.items.isEmpty { loadingState } else if viewModel.filteredItems.isEmpty && !viewModel.isLoading { emptyState } else { bookGrid } } .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) .navigationTitle("Library") .navigationBarTitleDisplayMode(.large) .toolbar { toolbarContent } .appNavigationDestination() .task { guard networkMonitor.isConnected else { return } await viewModel.load() } .refreshable { await viewModel.load() } .errorAlert($viewModel.error) .confirmationDialog("Sort By", isPresented: $showingSortSheet, titleVisibility: .visible) { ForEach(LibrarySortOrder.allCases, id: \.self) { order in Button(order.rawValue) { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { viewModel.sortOrder = order } } } Button("Cancel", role: .cancel) {} } } } // MARK: - Filter bar private var filterBar: some View { VStack(spacing: 0) { // Reading filter chips ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(LibraryReadingFilter.allCases, id: \.self) { filter in ChipButton(label: filter.rawValue, isSelected: viewModel.readingFilter == filter, style: .filled) { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { viewModel.readingFilter = filter } } } } .padding(.horizontal, 16) .padding(.vertical, 8) } // Genre chips (only show if there are genres) if viewModel.allGenres.count > 1 { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(viewModel.allGenres, id: \.self) { genre in ChipButton(label: genre, isSelected: viewModel.selectedGenre == genre, style: .outlined) { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { viewModel.selectedGenre = genre } } } } .padding(.horizontal, 16) .padding(.bottom, 8) } } Divider() .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) } } // MARK: - Book grid private let columns = [ GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12) ] private var bookGrid: some View { ScrollView { LazyVGrid(columns: columns, spacing: 16) { ForEach(viewModel.filteredItems) { item in NavigationLink(value: NavDestination.book(item.book.slug)) { LibraryBookCard( item: item, progress: viewModel.progressFraction(for: item), progressLabel: viewModel.progressPercent(for: item), isCompleted: viewModel.isCompleted(for: item), lastChapter: viewModel.lastChapter(for: item) ) .bookCoverZoomSource(slug: item.book.slug) .contextMenu { contextMenu(for: item) } } .buttonStyle(.plain) } } .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 120) // clear mini player } } // MARK: - Context menu @ViewBuilder private func contextMenu(for item: LibraryItem) -> some View { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() // Share: nothing to share without a URL from API, placeholder } label: { Label("Share", systemImage: "square.and.arrow.up") } if !viewModel.isCompleted(for: item) { Button { UIImpactFeedbackGenerator(style: .medium).impactOccurred() Task { await viewModel.markFinished(item: item) } } label: { Label("Mark as Finished", systemImage: "checkmark.circle") } } Button(role: .destructive) { UIImpactFeedbackGenerator(style: .medium).impactOccurred() Task { await viewModel.removeFromLibrary(slug: item.book.slug) } } label: { Label("Remove from Library", systemImage: "trash") } } // MARK: - Toolbar @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() showingSortSheet = true } label: { Label("Sort", systemImage: "arrow.up.arrow.down") .labelStyle(.iconOnly) } .accessibilityLabel("Sort library") } } // MARK: - Loading state private var loadingState: some View { ScrollView { LazyVGrid(columns: columns, spacing: 16) { ForEach(0..<8, id: \.self) { _ in LibraryBookCardSkeleton() } } .padding(.horizontal, 16) .padding(.top, 16) } } // MARK: - Empty state private var emptyState: some View { VStack { Spacer() EmptyStateView( icon: "books.vertical", title: viewModel.items.isEmpty ? "Your library is empty" : "No books match", message: viewModel.items.isEmpty ? "Browse and save books to build your collection." : "Try a different filter or genre.", ctaLabel: viewModel.items.isEmpty ? "Browse Books" : nil, ctaAction: nil ) Spacer() } } } // MARK: - LibraryBookCard struct LibraryBookCard: View { let item: LibraryItem let progress: Double // 0…1 let progressLabel: String // "47%" or "3.4%" let isCompleted: Bool let lastChapter: Int var body: some View { VStack(alignment: .leading, spacing: 6) { // Cover with progress arc overlay ZStack(alignment: .topTrailing) { AsyncCoverImage(url: item.book.cover) .aspectRatio(2/3, contentMode: .fill) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) if isCompleted { completedBadge } else if progress > 0 { progressArcBadge } } // Title Text(item.book.title) .font(.caption.bold()) .foregroundStyle(.primary) .lineLimit(2) // Chapter subtitle if lastChapter > 0 { Text(isCompleted ? "Completed" : "Ch. \(lastChapter)") .font(.caption2) .foregroundStyle(isCompleted ? Color.amber : .secondary) } } } // MARK: - Completed badge private var completedBadge: some View { Image(systemName: "checkmark.circle.fill") .font(.title3) .foregroundStyle(Color.amber) .padding(6) .background(.regularMaterial, in: Circle()) .padding(6) .accessibilityLabel("Completed") } // MARK: - Progress arc private var progressArcBadge: some View { ZStack { // Track Circle() .stroke(Color.white.opacity(0.25), lineWidth: 3) .frame(width: 32, height: 32) // Fill Circle() .trim(from: 0, to: progress) .stroke(Color.amber, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) .frame(width: 32, height: 32) .animation(.spring(response: 0.5, dampingFraction: 0.7), value: progress) Text(progressLabel) .font(.system(size: 7, weight: .bold)) .foregroundStyle(.white) } .padding(6) .background(.ultraThinMaterial, in: Circle()) .padding(6) .accessibilityLabel("Progress: \(progressLabel)") } } // MARK: - LibraryBookCardSkeleton // Shimmer placeholder used while data is loading. struct LibraryBookCardSkeleton: View { @State private var phase: Double = 0 var body: some View { VStack(alignment: .leading, spacing: 6) { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(shimmerGradient) .aspectRatio(2/3, contentMode: .fill) RoundedRectangle(cornerRadius: 4) .fill(shimmerGradient) .frame(height: 12) RoundedRectangle(cornerRadius: 4) .fill(shimmerGradient) .frame(width: 60, height: 10) } .onAppear { withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: true)) { phase = 1 } } } private var shimmerGradient: LinearGradient { LinearGradient( colors: [ Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)), Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)), Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)) ], startPoint: .topLeading, endPoint: .bottomTrailing ) } }