Files
libnovel/ios/LibNovelV2/Views/BookDetail/BookDetailView.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

672 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
// MARK: - BookDetailView
// Displays book hero (blurred cover bg + cover art + title), meta stats,
// expandable summary, CTA buttons, chapters row ( sheet), and bottom save toggle.
// Matches the web UI at ui/src/routes/books/[slug]/+page.svelte.
struct BookDetailView: View {
let slug: String
@State private var vm: BookDetailViewModel
@State private var showChapters = false
@State private var summaryExpanded = false
@EnvironmentObject private var networkMonitor: NetworkMonitor
@EnvironmentObject private var authStore: AuthStore
init(slug: String) {
self.slug = slug
_vm = State(initialValue: BookDetailViewModel(slug: slug))
}
var body: some View {
VStack(spacing: 0) {
OfflineBanner()
Group {
if vm.isLoading && vm.book == nil {
loadingState
} else if let book = vm.book {
content(book: book)
} else if vm.error != nil {
errorState
}
}
}
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
.navigationBarTitleDisplayMode(.inline)
.appNavigationDestination()
.toolbar { toolbarContent }
.task {
guard networkMonitor.isConnected else { return }
await vm.load()
}
.errorAlert($vm.error)
.sheet(isPresented: $showChapters) {
BookChaptersSheet(
slug: slug,
chapters: vm.chapters,
lastChapter: vm.lastChapter
)
}
}
// MARK: - Main content
private func content(book: Book) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
heroSection(book: book)
statsRow(book: book)
Divider()
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
.padding(.horizontal, 16)
summarySection(book: book)
Divider()
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
.padding(.horizontal, 16)
ctaButtons
Divider()
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
chaptersRow
Divider()
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
Color.clear.frame(height: 120)
}
}
.ignoresSafeArea(edges: .top)
}
// MARK: - Hero
private func heroSection(book: Book) -> some View {
ZStack(alignment: .bottom) {
// Blurred cover background
AsyncCoverImage(url: book.cover, isBackground: true)
.frame(maxWidth: .infinity)
.frame(height: 340)
.blur(radius: 28)
.clipped()
.overlay(
LinearGradient(
colors: [
Color.black.opacity(0.2),
Color.black.opacity(0.72),
],
startPoint: .top,
endPoint: .bottom
)
)
VStack(spacing: 16) {
// Cover art
AsyncCoverImage(url: book.cover)
.frame(width: 130, height: 188)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
// Title + author
VStack(spacing: 5) {
Text(book.title)
.font(.title3.bold())
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(.horizontal, 24)
if !book.author.isEmpty {
Text(book.author)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
}
}
// Status badge + genre chips
VStack(spacing: 8) {
if !book.status.isEmpty {
BookStatusBadge(status: book.status)
}
if !book.genres.isEmpty {
HStack(spacing: 6) {
ForEach(book.genres.prefix(3), id: \.self) { genre in
Text(genre)
.font(.caption2.bold())
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.ultraThinMaterial, in: Capsule())
.foregroundStyle(.white.opacity(0.9))
}
}
}
}
// "Not in library" badge
if !vm.inLib {
HStack(spacing: 6) {
Image(systemName: "icloud.and.arrow.down")
.font(.caption2)
Text("Not in library")
.font(.caption2)
}
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.regularMaterial, in: Capsule())
}
}
.padding(.horizontal)
.padding(.bottom, 28)
}
.frame(minHeight: 340)
}
// MARK: - Stats row
private func statsRow(book: Book) -> some View {
HStack(spacing: 0) {
BookMetaStat(
value: "\(vm.chapters.isEmpty ? book.totalChapters : vm.chapters.count)",
label: "Chapters",
icon: "doc.text"
)
Divider().frame(height: 36)
BookMetaStat(
value: book.status.isEmpty ? "" : book.status.capitalized,
label: "Status",
icon: "flag"
)
if book.ranking > 0 {
Divider().frame(height: 36)
BookMetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
}
}
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
}
// MARK: - Summary
private func summarySection(book: Book) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("About")
.font(.headline)
.padding(.horizontal, 16)
if book.summary.isEmpty {
Text("No description available.")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
} else {
Text(book.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(summaryExpanded ? nil : 4)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: summaryExpanded)
.padding(.horizontal, 16)
if book.summary.count > 200 {
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
summaryExpanded.toggle()
}
} label: {
Text(summaryExpanded ? "Less" : "More")
.font(.caption.bold())
.foregroundStyle(Color.amber)
}
.buttonStyle(.plain)
.frame(minWidth: 44, minHeight: 44)
.padding(.horizontal, 16)
}
}
}
.padding(.vertical, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - CTA buttons
private var ctaButtons: some View {
HStack(spacing: 10) {
if let last = vm.lastChapter, last > 0 {
// Continue reading
NavigationLink(value: NavDestination.chapter(slug, last)) {
Label("Continue Ch.\(last)", systemImage: "play.fill")
.font(.subheadline.bold())
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(Color.amber)
.foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.simultaneousGesture(TapGesture().onEnded {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
})
// Start from ch.1
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("Ch.1", systemImage: "arrow.counterclockwise")
.font(.subheadline.bold())
.frame(height: 44)
.padding(.horizontal, 16)
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
.foregroundStyle(.primary)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.simultaneousGesture(TapGesture().onEnded {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
})
} else {
// Start reading
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label(vm.inLib ? "Start Reading" : "Preview Ch.1", systemImage: "book.fill")
.font(.subheadline.bold())
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(vm.chapters.isEmpty ? Color.amber.opacity(0.4) : Color.amber)
.foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.disabled(vm.chapters.isEmpty)
.simultaneousGesture(TapGesture().onEnded {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
})
}
}
.padding(.horizontal, 16)
.padding(.vertical, 16)
}
// MARK: - Chapters row
private var chaptersRow: some View {
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
showChapters = true
} label: {
HStack(spacing: 12) {
Image(systemName: "list.number")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.amber)
.frame(width: 28)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text("Chapters")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
let count = vm.chapters.count
if let last = vm.lastChapter, last > 0, count > 0 {
Text("Reading Ch.\(last) of \(count)")
.font(.caption)
.foregroundStyle(.secondary)
} else if count > 0 {
Text("\(count) chapter\(count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
} else if vm.isLoading {
Text("Loading…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.frame(minHeight: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Chapters list")
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
Task { await vm.toggleSaved() }
} label: {
Image(systemName: vm.saved ? "bookmark.fill" : "bookmark")
.foregroundStyle(vm.saved ? Color.amber : .primary)
.contentTransition(.symbolEffect(.replace.downUp))
}
.disabled(vm.isSaving)
.accessibilityLabel(vm.saved ? "Remove from library" : "Save to library")
}
}
// MARK: - Loading / Error states
private var loadingState: some View {
VStack {
Spacer()
ProgressView()
.tint(Color.amber)
.scaleEffect(1.4)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var errorState: some View {
VStack {
Spacer()
EmptyStateView(
icon: "wifi.slash",
title: "Couldn't load book",
message: vm.error ?? "Something went wrong.",
ctaLabel: "Retry",
ctaAction: {
Task {
guard networkMonitor.isConnected else { return }
await vm.load()
}
}
)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - BookChaptersSheet
// Shows all chapters in groups of 100 with a searchable list and right-edge jump bar.
struct BookChaptersSheet: View {
let slug: String
let chapters: [ChapterIndex]
let lastChapter: Int?
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
private var filtered: [ChapterIndex] {
guard !searchText.isEmpty else { return chapters }
let q = searchText.lowercased()
return chapters.filter {
"\($0.number)".contains(q) || $0.title.lowercased().contains(q)
}
}
/// Chapters in blocks of 100, or a flat "Results" group when searching.
private var groups: [(label: String, chapters: [ChapterIndex])] {
guard searchText.isEmpty else {
return filtered.isEmpty ? [] : [("Results", filtered)]
}
guard !filtered.isEmpty else { return [] }
let blockSize = 100
let minN = filtered.map(\.number).min() ?? 1
let maxN = filtered.map(\.number).max() ?? 1
let firstBlock = ((minN - 1) / blockSize) * blockSize + 1
var result: [(label: String, chapters: [ChapterIndex])] = []
var blockStart = firstBlock
while blockStart <= maxN {
let blockEnd = blockStart + blockSize - 1
let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd }
if !slice.isEmpty { result.append(("\(blockStart)\(blockEnd)", slice)) }
blockStart += blockSize
}
return result
}
@State private var activeBlock: String?
var body: some View {
NavigationStack {
ZStack(alignment: .trailing) {
List {
ForEach(groups, id: \.label) { group in
Section {
ForEach(group.chapters, id: \.number) { ch in
ChapterListRow(
chapter: ch,
slug: slug,
isCurrent: ch.number == lastChapter
)
.id(ch.number)
}
} header: {
if searchText.isEmpty {
Text(group.label)
.font(.caption.bold())
.foregroundStyle(.secondary)
.id("header_\(group.label)")
}
}
}
if chapters.isEmpty {
Section {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.listRowBackground(Color.clear)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Chapter number or title"
)
.scrollPosition(id: $activeBlock, anchor: .top)
.appNavigationDestination()
// Jump bar (hidden while searching)
if searchText.isEmpty && groups.count > 1 {
ChapterJumpBar(
labels: groups.map(\.label),
currentChapter: lastChapter ?? 0,
groups: groups
) { label in
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
activeBlock = label
}
}
.padding(.trailing, 4)
}
}
.navigationTitle("Chapters (\(filtered.count))")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
.foregroundStyle(Color.amber)
}
}
.onAppear {
// Scroll to current chapter's block on open
if let block = groups.first(where: { g in
g.chapters.contains(where: { $0.number == (lastChapter ?? 0) })
}) {
activeBlock = block.label
}
}
}
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
// MARK: - ChapterListRow
private struct ChapterListRow: View {
let chapter: ChapterIndex
let slug: String
let isCurrent: Bool
private var displayTitle: String {
let pattern = #"\s*[-]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"#
let stripped = (try? NSRegularExpression(pattern: pattern))?
.stringByReplacingMatches(
in: chapter.title,
range: NSRange(chapter.title.startIndex..., in: chapter.title),
withTemplate: ""
).trimmingCharacters(in: .whitespaces) ?? chapter.title
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
return "Chapter \(chapter.number)"
}
return stripped
}
var body: some View {
NavigationLink(value: NavDestination.chapter(slug, chapter.number)) {
HStack(spacing: 14) {
// Number badge
ZStack {
Circle()
.fill(isCurrent ? Color.amber : Color(.systemGray5))
.frame(width: 40, height: 40)
Text("\(chapter.number)")
.font(.caption.bold().monospacedDigit())
.foregroundStyle(isCurrent ? .white : .secondary)
.minimumScaleFactor(0.6)
.frame(width: 40, height: 40)
}
VStack(alignment: .leading, spacing: 3) {
Text(displayTitle)
.font(.subheadline.weight(isCurrent ? .semibold : .regular))
.foregroundStyle(isCurrent ? Color.amber : .primary)
.lineLimit(1)
if isCurrent {
Label("Reading", systemImage: "bookmark.fill")
.font(.caption2)
.foregroundStyle(Color.amber)
} else if !chapter.dateLabel.isEmpty {
Text(chapter.dateLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Spacer(minLength: 4)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear)
.listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
}
}
// MARK: - ChapterJumpBar
private struct ChapterJumpBar: View {
let labels: [String]
let currentChapter: Int
let groups: [(label: String, chapters: [ChapterIndex])]
let onSelect: (String) -> Void
private func shortLabel(_ full: String) -> String {
full.components(separatedBy: "").first ?? full
}
private var currentBlock: String? {
groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label
}
var body: some View {
VStack(spacing: 0) {
ForEach(labels, id: \.self) { label in
let isCurrent = label == currentBlock
Text(shortLabel(label))
.font(.system(size: 10, weight: isCurrent ? .bold : .regular))
.foregroundStyle(isCurrent ? Color.amber : Color.secondary)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
.onTapGesture { onSelect(label) }
}
}
.padding(.vertical, 6)
.background(
Capsule()
.fill(.ultraThinMaterial)
.shadow(color: .black.opacity(0.15), radius: 4)
)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { value in
let itemHeight: CGFloat = 28
let index = Int(value.location.y / itemHeight)
let clamped = max(0, min(labels.count - 1, index))
onSelect(labels[clamped])
}
)
}
}
// MARK: - BookStatusBadge
private struct BookStatusBadge: View {
let status: String
private var color: Color {
switch status.lowercased() {
case "ongoing", "active": return .green
case "completed": return .blue
case "hiatus": return .orange
default: return .secondary
}
}
var body: some View {
HStack(spacing: 4) {
Circle().fill(color).frame(width: 6, height: 6)
Text(status.capitalized)
.font(.caption.weight(.medium))
.foregroundStyle(color)
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(color.opacity(0.12), in: Capsule())
}
}
// MARK: - BookMetaStat
private struct BookMetaStat: View {
let value: String
let label: String
let icon: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(Color.amber)
Text(value)
.font(.subheadline.bold())
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}