All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
385 lines
15 KiB
Swift
385 lines
15 KiB
Swift
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<Content: View>(@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)")
|
|
}
|
|
}
|