Files
libnovel/ios/LibNovelV2/Views/Home/HomeView.swift
Admin 7413313100
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
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

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)")
}
}