mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
157 lines
6.6 KiB
Swift
157 lines
6.6 KiB
Swift
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)")
|
|
}
|
|
}
|