mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-23 20:31:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class SourceSwitchViewModel: ObservableObject {
|
||||
enum State: Equatable { case idle, searching, ready, empty }
|
||||
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var candidates: [BookSourceCandidate] = []
|
||||
@Published private(set) var switchingID: String?
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let book: Book
|
||||
var bookName: String { book.name }
|
||||
private let service: BookSourceSwitchService
|
||||
private var selectionGeneration = 0
|
||||
|
||||
init(book: Book, service: BookSourceSwitchService = .init(), candidates: [BookSourceCandidate] = []) {
|
||||
self.book = book
|
||||
self.service = service
|
||||
self.candidates = candidates
|
||||
state = candidates.isEmpty ? .idle : .ready
|
||||
}
|
||||
|
||||
func load(sources: [BookSource]) async {
|
||||
guard state == .idle else { return }
|
||||
state = .searching
|
||||
for await update in await service.candidateUpdates(for: book, sources: sources) {
|
||||
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
|
||||
candidates = update
|
||||
}
|
||||
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
|
||||
state = candidates.isEmpty ? .empty : .ready
|
||||
}
|
||||
|
||||
func select(_ candidate: BookSourceCandidate, sources: [BookSource]) async -> (BookSourceSwitchReport, BookSource)? {
|
||||
guard switchingID == nil,
|
||||
let source = sources.first(where: { $0.bookSourceUrl == candidate.book.origin }) else { return nil }
|
||||
switchingID = candidate.id
|
||||
let generation = selectionGeneration
|
||||
errorMessage = nil
|
||||
defer { switchingID = nil }
|
||||
let report = await service.prepareSwitch(current: book, candidate: candidate.book, source: source)
|
||||
guard generation == selectionGeneration, !Task.isCancelled else { return nil }
|
||||
guard let report else {
|
||||
errorMessage = "候选书源无法读取目录或当前章节正文,请选择其他书源。已保留当前书源和阅读位置。"
|
||||
return nil
|
||||
}
|
||||
return (report, source)
|
||||
}
|
||||
|
||||
func clearError() { errorMessage = nil }
|
||||
func cancelSelection() { selectionGeneration &+= 1 }
|
||||
}
|
||||
|
||||
struct SourceSwitchView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var model: SourceSwitchViewModel
|
||||
let sources: [BookSource]
|
||||
let onSwitched: @MainActor (BookSourceSwitchReport, BookSource) -> Void
|
||||
|
||||
init(
|
||||
book: Book,
|
||||
sources: [BookSource],
|
||||
demoCandidates: [BookSourceCandidate] = [],
|
||||
onSwitched: @escaping @MainActor (BookSourceSwitchReport, BookSource) -> Void
|
||||
) {
|
||||
self.sources = sources
|
||||
self.onSwitched = onSwitched
|
||||
_model = StateObject(wrappedValue: SourceSwitchViewModel(book: book, candidates: demoCandidates))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.candidates.isEmpty && (model.state == .searching || model.state == .idle) {
|
||||
VStack(spacing: 14) {
|
||||
ProgressView()
|
||||
Text("正在从其他书源查找同名作品…").foregroundStyle(.secondary)
|
||||
}
|
||||
} else if model.state == .empty {
|
||||
ReadingUnavailableView(
|
||||
"没有可用换源",
|
||||
systemImage: "arrow.triangle.2.circlepath",
|
||||
description: Text("仅显示书名一致的候选,并优先匹配作者。请确认其他书源已启用且支持搜索。")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
if model.state == .searching {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("仍在查找其他书源,可直接选择已找到的结果")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
ForEach(model.candidates) { candidate in candidateRow(candidate) }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.readingScrollContentBackgroundHidden()
|
||||
.background(ReadingTheme.canvas)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationTitle("为《\(model.bookName)》换源")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
}
|
||||
.task { await model.load(sources: sources) }
|
||||
.onDisappear { model.cancelSelection() }
|
||||
.alert("换源失败", isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.clearError() } }
|
||||
)) {
|
||||
Button("好") { model.clearError() }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func candidateRow(_ candidate: BookSourceCandidate) -> some View {
|
||||
Button {
|
||||
Task {
|
||||
if let (report, source) = await model.select(candidate, sources: sources) {
|
||||
onSwitched(report, source)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 13) {
|
||||
Image(systemName: "network")
|
||||
.foregroundStyle(ReadingTheme.accent)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 13))
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(candidate.book.originName).font(.headline).foregroundStyle(.primary)
|
||||
Text(candidate.book.author.isEmpty ? "作者信息缺失" : candidate.book.author)
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
if model.switchingID == candidate.id {
|
||||
Text("正在验证目录和正文…").font(.caption).foregroundStyle(.secondary)
|
||||
} else if let latest = candidate.book.latestChapterTitle, !latest.isEmpty {
|
||||
Text(latest).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if model.switchingID == candidate.id {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "arrow.right.circle.fill").foregroundStyle(ReadingTheme.coral)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
.disabled(model.switchingID != nil)
|
||||
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user