import SwiftUI // MARK: - HomeView // "Reading Now" tab: stats bar + Continue Reading shelf + Recently Updated shelf // + Subscription Feed shelf + empty state. // Design mirrors the web UI home page (zinc-900 bg, amber accents, horizontal shelves). struct HomeView: View { @State private var vm = HomeViewModel() @EnvironmentObject var networkMonitor: NetworkMonitor @EnvironmentObject var authStore: AuthStore var body: some View { NavigationStack { VStack(spacing: 0) { OfflineBanner() ScrollView { LazyVStack(alignment: .leading, spacing: 0) { // ── Stats bar ─────────────────────────────────────── if let stats = vm.stats { StatsBar(stats: stats) .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 28) .transition(.opacity) } // ── Continue Reading ──────────────────────────────── if !vm.continueReading.isEmpty { ShelfHeader(title: "Continue Reading") horizontalShelf { ForEach(vm.continueReading) { item in NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) { ContinueReadingCard(item: item) } .buttonStyle(.plain) .contextMenu { continueReadingContextMenu(item: item) } } } } // ── Recently Updated ──────────────────────────────── if !vm.recentlyUpdated.isEmpty { ShelfHeader(title: "Recently Updated") horizontalShelf { ForEach(vm.recentlyUpdated) { book in NavigationLink(value: NavDestination.book(book.slug)) { ShelfBookCard(book: book) } .buttonStyle(.plain) } } } // ── Subscription Feed ─────────────────────────────── if !vm.subscriptionFeed.isEmpty { ShelfHeader(title: "From People You Follow") horizontalShelf { ForEach(vm.subscriptionFeed) { item in NavigationLink(value: NavDestination.book(item.book.slug)) { SubscriptionFeedCard(item: item) } .buttonStyle(.plain) } } } // ── Empty state ───────────────────────────────────── if !vm.isLoading && vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty { EmptyStateView( icon: "books.vertical", title: "Your library is empty", message: "Head to Discover to find novels to read.", ctaLabel: "Discover Novels", ctaAction: nil // tab switching handled externally ) .frame(maxWidth: .infinity) .padding(.top, 60) } // ── Loading indicator ─────────────────────────────── if vm.isLoading { ProgressView() .frame(maxWidth: .infinity) .padding(.top, 60) } Color.clear.frame(height: 24) } } .refreshable { await vm.load() } } .navigationTitle("Reading Now") .appNavigationDestination() .task { guard networkMonitor.isConnected else { return } await vm.load() } .errorAlert($vm.error) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: vm.isLoading) } } // MARK: - Horizontal shelf wrapper @ViewBuilder private func horizontalShelf(@ViewBuilder content: () -> Content) -> some View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(alignment: .top, spacing: 14) { content() } .padding(.horizontal, 16) .padding(.bottom, 4) } .padding(.bottom, 28) } // MARK: - Context menu for continue reading cards @ViewBuilder private func continueReadingContextMenu(item: ContinueReadingItem) -> some View { let isFinished = item.book.totalChapters > 0 && item.chapter >= item.book.totalChapters ShareLink(item: shareURL(for: item.book)) { Label("Share", systemImage: "square.and.arrow.up") } if !isFinished { Button { UIImpactFeedbackGenerator(style: .medium).impactOccurred() Task { await markAsFinished(item.book) } } label: { Label("Mark as Finished", systemImage: "checkmark.circle") } } Button(role: .destructive) { Task { await removeFromLibrary(item.book.slug) } } label: { Label("Remove from Library", systemImage: "trash") } } // MARK: - Actions private func markAsFinished(_ book: Book) async { do { try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters) await vm.load() } catch { vm.error = error.localizedDescription } } private func removeFromLibrary(_ slug: String) async { do { try await APIClient.shared.deleteProgress(slug: slug) await vm.load() } catch { vm.error = error.localizedDescription } } private func shareURL(for book: Book) -> URL { let base = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String ?? "https://v2.libnovel.kalekber.cc" return URL(string: "\(base)/books/\(book.slug)")! } } // MARK: - Stats bar // Three amber-value cards: Books / Chapters / In Progress private struct StatsBar: View { let stats: HomeStats var body: some View { HStack(spacing: 12) { StatCard( icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books" ) StatCard( icon: "text.alignleft", value: stats.totalChapters.formatted(), label: "Chapters" ) StatCard( icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress" ) } } } private struct StatCard: View { let icon: String let value: String let label: String var body: some View { VStack(spacing: 5) { Image(systemName: icon) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(Color.amber) Text(value) .font(.title3.bold().monospacedDigit()) .foregroundStyle(.primary) Text(label) .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } } // MARK: - Continue Reading card (Apple Books style with progress bar) private struct ContinueReadingCard: View { let item: ContinueReadingItem private static let cardWidth: CGFloat = 130 private static let cardHeight: CGFloat = 188 // 2:3 aspect private var progressFraction: Double { guard item.book.totalChapters > 0 else { return 0 } return min(1.0, Double(item.chapter) / Double(item.book.totalChapters)) } private var progressText: String { let pct = progressFraction * 100 if pct > 0 && pct < 10 { return String(format: "%.1f%% complete", pct) } return "\(max(1, Int(round(pct))))% complete" } var body: some View { VStack(alignment: .leading, spacing: 8) { // Cover with gradient scrim + chapter badge ZStack(alignment: .bottom) { AsyncCoverImage(url: item.book.cover) .frame(width: Self.cardWidth, height: Self.cardHeight) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .shadow(color: .black.opacity(0.22), radius: 8, y: 4) .bookCoverZoomSource(slug: item.book.slug) // Gradient scrim LinearGradient( colors: [.clear, .black.opacity(0.55)], startPoint: .center, endPoint: .bottom ) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .frame(height: 60) // Chapter pill HStack(spacing: 4) { Image(systemName: "play.fill") .font(.system(size: 8, weight: .bold)) Text("Ch.\(item.chapter)") .font(.system(size: 10, weight: .bold)) } .foregroundStyle(.white) .padding(.horizontal, 9) .padding(.vertical, 5) .background(Capsule().fill(Color.amber)) .padding(.bottom, 10) } // Title Text(item.book.title) .font(.caption.bold()) .lineLimit(2) .frame(width: Self.cardWidth, alignment: .leading) .foregroundStyle(.primary) // Progress bar (min 4pt sliver so early chapters are visible) GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(Color.secondary.opacity(0.2)) Capsule() .fill(Color.amber.opacity(0.9)) .frame(width: max(4, geo.size.width * progressFraction)) } } .frame(width: Self.cardWidth, height: 3) Text(progressText) .font(.caption2) .foregroundStyle(.secondary) } .frame(width: Self.cardWidth) .accessibilityElement(children: .combine) .accessibilityLabel("\(item.book.title), chapter \(item.chapter), \(progressText)") } } // MARK: - Shelf book card (recently updated) private struct ShelfBookCard: View { let book: Book private static let cardWidth: CGFloat = 110 private static let cardHeight: CGFloat = 158 var body: some View { VStack(alignment: .leading, spacing: 6) { ZStack(alignment: .topTrailing) { AsyncCoverImage(url: book.cover) .frame(width: Self.cardWidth, height: Self.cardHeight) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 4, y: 2) .bookCoverZoomSource(slug: book.slug) Text("\(book.totalChapters) ch") .font(.system(size: 9, weight: .bold)) .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 3) .background(Capsule().fill(Color.black.opacity(0.55))) .padding(6) } Text(book.title) .font(.caption.bold()) .lineLimit(2) .frame(width: Self.cardWidth, alignment: .leading) Text(book.author) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .frame(width: Self.cardWidth, alignment: .leading) } .accessibilityElement(children: .combine) .accessibilityLabel("\(book.title) by \(book.author), \(book.totalChapters) chapters") } } // MARK: - Subscription feed card private struct SubscriptionFeedCard: View { let item: SubscriptionFeedItem private static let cardWidth: CGFloat = 110 private static let cardHeight: CGFloat = 158 var body: some View { VStack(alignment: .leading, spacing: 6) { AsyncCoverImage(url: item.book.cover) .frame(width: Self.cardWidth, height: Self.cardHeight) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 4, y: 2) .bookCoverZoomSource(slug: item.book.slug) Text(item.book.title) .font(.caption.bold()) .lineLimit(2) .frame(width: Self.cardWidth, alignment: .leading) NavigationLink(value: NavDestination.userProfile(item.readerUsername)) { Text("via @\(item.readerUsername)") .font(.caption2) .foregroundStyle(Color.amber) .lineLimit(1) .frame(width: Self.cardWidth, alignment: .leading) } .buttonStyle(.plain) } .accessibilityElement(children: .combine) .accessibilityLabel("\(item.book.title), via \(item.readerUsername)") } }