mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user