mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
355 lines
14 KiB
Swift
355 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
@MainActor
|
|
final class ExploreSourceViewModel: ObservableObject {
|
|
enum State: Equatable { case idle, loading, ready, empty, failed }
|
|
|
|
let source: BookSource
|
|
@Published private(set) var kinds: [ExploreKind] = []
|
|
@Published private(set) var selectedKind: ExploreKind?
|
|
@Published private(set) var books: [SearchBook] = []
|
|
@Published private(set) var diagnostics: [RuleDiagnostic] = []
|
|
@Published private(set) var state: State = .idle
|
|
@Published private(set) var isLoadingMore = false
|
|
|
|
private let service: BookExploreService
|
|
private var page = 1
|
|
private var canLoadMore = false
|
|
private var paginationTask: Task<Void, Never>?
|
|
|
|
init(source: BookSource, service: BookExploreService = .init()) {
|
|
self.source = source
|
|
self.service = service
|
|
}
|
|
|
|
deinit { paginationTask?.cancel() }
|
|
|
|
func load() async {
|
|
guard state == .idle else { return }
|
|
await reload()
|
|
}
|
|
|
|
func reload() async {
|
|
paginationTask?.cancel()
|
|
selectedKind = nil
|
|
kinds = []
|
|
books = []
|
|
diagnostics = []
|
|
page = 1
|
|
canLoadMore = false
|
|
state = .loading
|
|
|
|
let report = await service.kinds(for: source)
|
|
guard !Task.isCancelled else { return }
|
|
kinds = report.kinds.filter { $0.navigableURL != nil }
|
|
diagnostics = report.diagnostics
|
|
guard let first = kinds.first else {
|
|
state = diagnostics.contains(where: { $0.severity == .error }) ? .failed : .empty
|
|
return
|
|
}
|
|
await select(kind: first)
|
|
}
|
|
|
|
func select(kind: ExploreKind) async {
|
|
guard kind.navigableURL != nil else { return }
|
|
paginationTask?.cancel()
|
|
selectedKind = kind
|
|
books = []
|
|
diagnostics = []
|
|
page = 1
|
|
canLoadMore = false
|
|
state = .loading
|
|
|
|
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
|
|
books = DemoContent.exploreBooks
|
|
state = .ready
|
|
canLoadMore = true
|
|
return
|
|
}
|
|
|
|
let outcome = await service.explore(source: source, kind: kind, page: page)
|
|
guard !Task.isCancelled, selectedKind?.id == kind.id else { return }
|
|
diagnostics = outcome.diagnostics
|
|
books = outcome.books
|
|
canLoadMore = !outcome.books.isEmpty
|
|
state = books.isEmpty ? (outcome.diagnostics.isEmpty ? .empty : .failed) : .ready
|
|
}
|
|
|
|
func loadNextPageIfNeeded(current book: SearchBook) {
|
|
guard book.id == books.last?.id,
|
|
canLoadMore,
|
|
!isLoadingMore,
|
|
let kind = selectedKind else { return }
|
|
isLoadingMore = true
|
|
let nextPage = page + 1
|
|
paginationTask = Task { [service, source] in
|
|
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
|
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
guard !Task.isCancelled else { return }
|
|
canLoadMore = false
|
|
isLoadingMore = false
|
|
return
|
|
}
|
|
let outcome = await service.explore(source: source, kind: kind, page: nextPage)
|
|
guard !Task.isCancelled, selectedKind?.id == kind.id else {
|
|
isLoadingMore = false
|
|
return
|
|
}
|
|
let knownURLs = Set(books.map(\.bookURL))
|
|
books.append(contentsOf: outcome.books.filter { !knownURLs.contains($0.bookURL) })
|
|
diagnostics += outcome.diagnostics
|
|
page = nextPage
|
|
canLoadMore = !outcome.books.isEmpty
|
|
isLoadingMore = false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ExploreView: View {
|
|
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
|
|
|
|
private var sources: [BookSource] {
|
|
sourceLibrary.sources
|
|
.filter { $0.enabled && $0.enabledExplore && !($0.exploreUrl?.isEmpty ?? true) }
|
|
.sorted {
|
|
if $0.customOrder != $1.customOrder { return $0.customOrder < $1.customOrder }
|
|
return $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ReadingPageHeader(title: "发现", subtitle: "下一本好书,在这里遇见") {
|
|
NavigationLink { SearchView() } label: {
|
|
ReadingToolbarIcon(systemName: "magnifyingglass")
|
|
}
|
|
.accessibilityLabel("搜索")
|
|
}
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
HStack(spacing: 16) {
|
|
Image(systemName: "safari")
|
|
.font(.system(size: 30, weight: .ultraLight))
|
|
.accessibilityHidden(true)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("从喜欢的书源开始").font(.headline)
|
|
Text("浏览分类与榜单,发现更多故事")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(22)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20))
|
|
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text("分类与榜单").font(.title3.bold())
|
|
.accessibilityAddTraits(.isHeader)
|
|
Spacer()
|
|
Text("\(sources.count)个书源").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
if sources.isEmpty {
|
|
ReadingUnavailableView {
|
|
Label("暂无发现书源", systemImage: "books.vertical")
|
|
} description: {
|
|
Text("添加带有发现分类的书源,即可在这里浏览书籍。")
|
|
} actions: {
|
|
NavigationLink("管理书源") { SourceManagementView() }
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
} else {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(sources) { source in
|
|
NavigationLink { ExploreSourceView(source: source) } label: {
|
|
HStack(spacing: 12) {
|
|
ExploreSourceRow(source: source)
|
|
Spacer(minLength: 0)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption).foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 8)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
if source.id != sources.last?.id {
|
|
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
|
|
.padding(.leading, 58)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
NavigationLink { SourceManagementView() } label: {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "slider.horizontal.3")
|
|
.font(.system(size: 20))
|
|
.frame(width: 40, height: 44)
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text("管理书源").font(.subheadline.weight(.semibold))
|
|
Text("添加与整理阅读来源").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
Image(systemName: "arrow.up.right").font(.subheadline)
|
|
}
|
|
.foregroundStyle(ReadingTheme.ink)
|
|
.padding(18)
|
|
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 18))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.bottom, 32)
|
|
}
|
|
}
|
|
.background(ReadingTheme.canvas)
|
|
.navigationBarHidden(true)
|
|
}
|
|
}
|
|
|
|
private struct ExploreSourceRow: View {
|
|
let source: BookSource
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: source.bookSourceType == 1 ? "headphones" : "books.vertical")
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(ReadingTheme.accent)
|
|
.frame(width: 44, height: 48)
|
|
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 12))
|
|
.accessibilityHidden(true)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(source.bookSourceName)
|
|
.font(.body.weight(.medium))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(2)
|
|
Text(source.groups.isEmpty ? (URL(string: source.bookSourceUrl)?.host ?? source.bookSourceUrl) : source.groups.joined(separator: " · "))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.padding(.vertical, 7)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
private struct ExploreSourceView: View {
|
|
@StateObject private var model: ExploreSourceViewModel
|
|
|
|
init(source: BookSource) {
|
|
_model = StateObject(wrappedValue: ExploreSourceViewModel(source: source))
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
if model.kinds.count > 1 { kindPicker }
|
|
content
|
|
if model.isLoadingMore { ProgressView("继续加载…").padding(.vertical, 12) }
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.background(ReadingTheme.canvas)
|
|
.navigationTitle(model.source.bookSourceName)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.task { await model.load() }
|
|
.refreshable { await model.reload() }
|
|
}
|
|
|
|
private var kindPicker: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(model.kinds) { kind in
|
|
Button(kind.title) { Task { await model.select(kind: kind) } }
|
|
.font(.subheadline.weight(.medium))
|
|
.padding(.horizontal, 12)
|
|
.frame(minHeight: 44)
|
|
.foregroundStyle(model.selectedKind?.id == kind.id ? Color.primary : .secondary)
|
|
.overlay(alignment: .bottom) {
|
|
if model.selectedKind?.id == kind.id {
|
|
Rectangle().fill(Color.primary).frame(height: 2)
|
|
}
|
|
}
|
|
.accessibilityAddTraits(model.selectedKind?.id == kind.id ? .isSelected : [])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
switch model.state {
|
|
case .idle, .loading:
|
|
ProgressView("正在加载小说…")
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 80)
|
|
case .empty:
|
|
ReadingUnavailableView(
|
|
"暂无小说",
|
|
systemImage: "books.vertical",
|
|
description: Text("当前书源或分类暂时没有内容,可以下拉刷新。")
|
|
)
|
|
.padding(.vertical, 40)
|
|
case .failed where model.books.isEmpty:
|
|
ReadingUnavailableView(
|
|
"加载失败",
|
|
systemImage: "exclamationmark.triangle",
|
|
description: Text(model.diagnostics.first?.message ?? "书源发现规则解析失败。")
|
|
)
|
|
.padding(.vertical, 40)
|
|
default:
|
|
ForEach(model.books) { book in
|
|
NavigationLink {
|
|
BookDetailView(searchBook: book, source: model.source)
|
|
} label: {
|
|
ExploreBookRow(book: book)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.onAppear { model.loadNextPageIfNeeded(current: book) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ExploreBookRow: View {
|
|
let book: SearchBook
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 13) {
|
|
BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.coverURL, width: 52, height: 74)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(book.name)
|
|
.font(.body.weight(.medium))
|
|
.foregroundStyle(ReadingTheme.ink)
|
|
.lineLimit(2)
|
|
Text(book.author.isEmpty ? "未知作者" : book.author)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
if let kind = book.kind, !kind.isEmpty {
|
|
Text(kind)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
if let intro = book.intro, !intro.isEmpty {
|
|
Text(intro)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.top, 4)
|
|
}
|
|
.padding(.vertical, 12)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
|
|
.padding(.leading, 65)
|
|
}
|
|
}
|
|
}
|