Files
legado-ios/App/Features/Books/BookDetailView.swift
T

358 lines
16 KiB
Swift

import SwiftUI
struct BookDetailView: View {
@EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@StateObject private var model: BookDetailViewModel
@State private var showsSourceSwitch = false
@State private var showsRemoveConfirmation = false
@State private var showsTableOfContents = false
init(searchBook: SearchBook, source: BookSource) {
_model = StateObject(wrappedValue: BookDetailViewModel(searchBook: searchBook, source: source))
}
init(book: Book, source: BookSource) {
_model = StateObject(wrappedValue: BookDetailViewModel(book: book, source: source))
}
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
_model = StateObject(wrappedValue: BookDetailViewModel(demoBook: demoBook, source: source, chapters: chapters))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
hero
Divider()
if let intro = MetadataTextSanitizer().clean(model.book.intro), !intro.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("简介").font(.headline)
Text(intro).font(.subheadline).foregroundStyle(.secondary).lineSpacing(5)
}
Divider()
}
chapterCard
if model.isCaching || model.cacheCompleted > 0 { cacheProgressCard }
}
.padding(.horizontal, 20)
.padding(.bottom, 30)
}
.background(ReadingTheme.canvas)
.navigationTitle("书籍详情")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("换源", systemImage: "arrow.triangle.2.circlepath") { showsSourceSwitch = true }
.disabled(sourceLibrary.sources.filter(\.enabled).count < 2)
Button("允许更新", systemImage: model.book.canUpdate ? "checkmark" : "circle") {
model.toggleCanUpdate()
Task { await saveCurrentBook() }
}
Button("刷新", systemImage: "arrow.clockwise") {
Task {
let oldURL = model.book.bookUrl
let wasInLibrary = library.contains(model.book)
await model.refresh(sources: sourceLibrary.sources)
if wasInLibrary, oldURL != model.book.bookUrl {
await library.replace(oldBookURL: oldURL, with: model.book)
} else if wasInLibrary {
await saveCurrentBook()
}
}
}
if model.source.loginUrl?.isEmpty == false {
NavigationLink {
SourceLoginView(source: model.source)
} label: {
Label("登录", systemImage: "person.badge.key")
}
}
ReadingShareLink(item: model.book.bookUrl) {
Label("分享", systemImage: "square.and.arrow.up")
}
Divider()
Button("缓存接下来 10 章", systemImage: "arrow.down.circle") { model.cacheNextTen() }
.disabled(model.chapters.isEmpty || model.isCaching)
Button("缓存剩余章节", systemImage: "square.and.arrow.down.on.square") { model.cacheRemaining() }
.disabled(model.chapters.isEmpty || model.isCaching)
if model.isCaching {
Divider()
Button("停止缓存", systemImage: "stop.circle", role: .destructive) { model.cancelCaching() }
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.task {
model.restoreSourceAfterReading(from: library.books, sources: sourceLibrary.sources)
let oldURL = model.book.bookUrl
let shouldPersistUpdate = library.contains(model.book)
if let stored = library.books.first(where: { $0.bookUrl == oldURL }) {
model.restoreLibraryBook(from: stored, sources: sourceLibrary.sources)
}
await model.load(sources: sourceLibrary.sources)
if shouldPersistUpdate {
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
model.restoreUserSettings(from: stored)
model.restoreReadingProgress(from: stored)
}
if oldURL != model.book.bookUrl {
await library.replace(oldBookURL: oldURL, with: model.book)
} else {
await saveCurrentBook()
}
}
}
.sheet(isPresented: $showsSourceSwitch) {
ReadingNavigationStack {
SourceSwitchView(book: model.book, sources: sourceLibrary.sources) { report, source in
let oldURL = model.book.bookUrl
model.applySourceSwitch(report, source: source)
Task { await library.replace(oldBookURL: oldURL, with: report.book) }
}
}
}
.sheet(isPresented: $showsTableOfContents) {
BookTableOfContentsView(book: model.book, chapters: model.chapters, source: model.source)
}
.confirmationDialog("从书架移除《\(model.book.name)》?", isPresented: $showsRemoveConfirmation) {
Button("移出书架", role: .destructive) {
Task { await library.remove(bookURL: model.book.bookUrl) }
}
Button("取消", role: .cancel) { }
}
}
private var cacheProgressCard: some View {
ReadingCard {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label(model.isCaching ? "正在缓存章节" : "章节缓存完成", systemImage: model.isCaching ? "arrow.down.circle.fill" : "checkmark.circle.fill")
.font(.headline)
Spacer()
Text("\(model.cacheCompleted)/\(model.cacheTotal)")
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
ProgressView(value: Double(model.cacheCompleted), total: Double(max(model.cacheTotal, 1)))
.tint(ReadingTheme.accent)
if model.isCaching {
Button("停止缓存", role: .destructive) { model.cancelCaching() }
.font(.subheadline)
}
}
}
}
private var hero: some View {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 18) {
BookCoverArtwork(
title: model.book.name,
author: model.book.author,
remoteURL: model.book.coverUrl,
width: 88,
height: 126
)
VStack(alignment: .leading, spacing: 8) {
Text(model.book.name).font(.title2.weight(.semibold)).lineLimit(3)
Text(model.book.author.isEmpty ? "未知作者" : model.book.author)
.font(.subheadline).foregroundStyle(.secondary)
if let kind = model.book.kind, !kind.isEmpty {
Text(kind).font(.caption).foregroundStyle(.secondary)
}
Text(model.source.bookSourceName)
.font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
if let latest = model.book.latestChapterTitle, !latest.isEmpty {
Text("更新至 \(latest)")
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Group {
if dynamicTypeSize.isAccessibilitySize {
VStack(spacing: 12) { readingActions }
} else {
HStack(spacing: 12) { readingActions }
}
}
.controlSize(.large)
}
.padding(.top, 16)
}
@ViewBuilder
private var readingActions: some View {
NavigationLink {
readingDestination
} label: {
Text(model.book.durChapterIndex > 0 || model.book.durChapterPos > 0 ? "继续阅读" : "开始阅读")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 28)
}
.buttonStyle(.borderedProminent)
.disabled(model.chapters.isEmpty)
Button {
if library.contains(model.book) {
showsRemoveConfirmation = true
} else {
Task { await saveCurrentBook() }
}
} label: {
Label(
library.contains(model.book) ? "已在书架" : "加入书架",
systemImage: library.contains(model.book) ? "checkmark" : "plus"
)
.font(.subheadline)
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28)
}
.buttonStyle(.bordered)
}
@ViewBuilder
private var readingDestination: some View {
let index = min(max(model.book.durChapterIndex, 0), max(model.chapters.count - 1, 0))
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
ReaderView(demoBook: model.book, chapters: model.chapters, initialIndex: index, content: DemoContent.readerText)
} else {
ReaderView(book: model.book, chapters: model.chapters, initialIndex: index, source: model.source)
}
}
private func saveCurrentBook() async {
await library.addOrUpdate(model.book)
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
model.restoreReadingProgress(from: stored)
}
}
private var chapterCard: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("目录").font(.headline)
Spacer()
Text("\(model.chapters.count) 章").font(.caption).foregroundStyle(.secondary)
}
.padding(.bottom, 12)
if model.state == .loading || model.state == .idle {
HStack { Spacer(); ProgressView("正在解析目录…"); Spacer() }.padding(.vertical, 28)
} else if model.state == .findingSource {
HStack { Spacer(); ProgressView("当前源不可用,正在自动寻找可用源…"); Spacer() }
.padding(.vertical, 28)
} else if model.state == .failed {
ReadingUnavailableView(
"没有找到可用目录",
systemImage: "exclamationmark.arrow.triangle.2.circlepath",
description: Text("已自动尝试其他可搜索书源,可稍后刷新重试或手动换源。")
)
.padding(.vertical, 12)
} else {
ForEach(Array(model.chapters.filter { !$0.isVolume }.prefix(8).enumerated()), id: \.element.id) { offset, chapter in
if offset > 0 { Divider() }
if chapter.isVolume {
Text(chapter.title).font(.subheadline.bold()).foregroundStyle(ReadingTheme.accent)
.padding(.vertical, 12)
} else {
NavigationLink {
ReaderView(
book: model.book,
chapters: model.chapters,
initialIndex: chapter.index,
source: model.source
)
} label: {
HStack(spacing: 12) {
Text(String(format: "%02d", chapter.index + 1))
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary).frame(width: 28)
Text(chapter.title).font(.subheadline).foregroundStyle(.primary).lineLimit(2)
Spacer()
if chapter.isVip { Image(systemName: "lock.fill").font(.caption).foregroundStyle(.orange) }
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
}
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
}
if model.chapters.count > 8 {
Divider()
Button {
showsTableOfContents = true
} label: {
Label("查看全部目录", systemImage: "list.bullet")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
}
}
}
}
}
private struct BookTableOfContentsView: View {
@Environment(\.dismiss) private var dismiss
let book: Book
let chapters: [BookChapter]
let source: BookSource
@State private var searchText = ""
@State private var descending = false
private var displayedChapters: [BookChapter] {
let filtered = searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? chapters
: chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
return descending ? Array(filtered.reversed()) : filtered
}
var body: some View {
ReadingNavigationStack {
List(displayedChapters) { chapter in
if chapter.isVolume {
Text(chapter.title).font(.headline).foregroundStyle(ReadingTheme.accent)
} else {
NavigationLink {
ReaderView(book: book, chapters: chapters, initialIndex: chapter.index, source: source)
} label: {
HStack(spacing: 12) {
Text("\(chapter.index + 1)")
.font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 42, alignment: .trailing)
Text(chapter.title).lineLimit(2)
Spacer()
if chapter.index == book.durChapterIndex {
Image(systemName: "location.fill").foregroundStyle(ReadingTheme.accent)
}
if chapter.isVip { Image(systemName: "lock.fill").foregroundStyle(.orange) }
}
}
}
}
.searchable(text: $searchText, prompt: "搜索目录")
.navigationTitle("目录")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button(descending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") {
descending.toggle()
}
}
}
.safeAreaInset(edge: .bottom) {
HStack {
Text(book.durChapterTitle ?? "尚未阅读").lineLimit(1)
Spacer()
Text("\(min(book.durChapterIndex + 1, chapters.count))/\(chapters.count)")
.monospacedDigit().foregroundStyle(.secondary)
}
.font(.caption)
.padding(.horizontal, 16).padding(.vertical, 10)
.background(.bar)
}
}
}
}