Files
legado-ios/App/RootTabView.swift
T

2104 lines
87 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import UniformTypeIdentifiers
struct RootTabView: View {
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
private enum Tab: Hashable { case bookshelf, explore, statistics, my }
@State private var selection: Tab
@State private var showsDemoReader = false
@State private var didOpenDemoReader = false
@State private var sourceImportIsPresented = false
@State private var displayedSourceNotice: SourceLibraryModel.Notice?
@AppStorage("themeMode") private var themeMode = "0"
init() {
let arguments = ProcessInfo.processInfo.arguments
let requested = arguments.firstIndex(of: "-uiDemoTab")
.flatMap { arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil }
let initial: Tab = switch requested {
case "explore": .explore
case "statistics": .statistics
case "settings": .my
default:
switch UserDefaults.standard.string(forKey: "defaultHomePage") {
case "explore": .explore
case "statistics": .statistics
case "settings": .my
default: .bookshelf
}
}
_selection = State(initialValue: initial)
}
var body: some View {
TabView(selection: $selection) {
ReadingNavigationStack {
Group {
if ProcessInfo.processInfo.arguments.contains("-uiDemoSourceSwitch") {
SourceSwitchView(
book: DemoContent.books[0],
sources: DemoContent.sources,
demoCandidates: DemoContent.sourceSwitchCandidates
) { _, _ in }
} else if ProcessInfo.processInfo.arguments.contains("-uiDemoBookDetail") {
BookDetailView(
demoBook: DemoContent.books[0],
source: DemoContent.sources[0],
chapters: DemoContent.readerChapters
)
} else if requestedDemoPage == "search" {
SearchView(initialKeyword: automationSearchKeyword)
} else if requestedDemoPage == "sources" {
SourceManagementView()
} else {
BookshelfView()
}
}
.readingNavigationDestination(isPresented: $showsDemoReader) {
ReaderView(
demoBook: DemoContent.books[0],
chapters: DemoContent.readerChapters,
initialIndex: 0,
content: DemoContent.readerText
)
}
.task {
guard !didOpenDemoReader else { return }
didOpenDemoReader = true
showsDemoReader = ProcessInfo.processInfo.arguments.contains("-uiDemoReader")
}
}
.tabItem { Label("书架", systemImage: "book.fill") }
.tag(Tab.bookshelf)
ReadingNavigationStack { ExploreView() }
.tabItem { Label("发现", systemImage: "safari") }
.tag(Tab.explore)
ReadingNavigationStack { ReadingStatisticsView() }
.tabItem { Label("统计", systemImage: "chart.bar") }
.tag(Tab.statistics)
ReadingNavigationStack {
if ProcessInfo.processInfo.arguments.contains("-uiDemoRules") {
ReplaceRuleManagementView()
} else if ProcessInfo.processInfo.arguments.contains("-uiDemoHistory") {
ReadingHistoryView()
} else if ProcessInfo.processInfo.arguments.contains("-uiDemoFiles") {
FileManagementView()
} else if ProcessInfo.processInfo.arguments.contains("-uiDemoOtherSettings") {
OtherSettingsView()
} else if ProcessInfo.processInfo.arguments.contains("-uiDemoAbout") {
AboutView()
} else if ProcessInfo.processInfo.arguments.contains("-uiDemoLogs") {
DiagnosticLogView()
} else {
SettingsView()
}
}
.tabItem { Label("我的", systemImage: "person.crop.circle") }
.tag(Tab.my)
}
.tint(ReadingTheme.accent)
.readingTabBarBackground()
.preferredColorScheme(themeMode == "2" ? .dark : (themeMode == "1" || themeMode == "3" ? .light : nil))
.transaction { transaction in
if themeMode == "3" { transaction.animation = nil }
}
.sheet(item: $sourceLibrary.pendingImport, onDismiss: {
sourceImportIsPresented = false
displayedSourceNotice = sourceLibrary.notice
}) { pending in
SourceImportReviewView(pending: pending)
.environmentObject(sourceLibrary)
.onAppear { sourceImportIsPresented = true }
}
.onChange(of: sourceLibrary.notice?.id) { _ in
if !sourceImportIsPresented { displayedSourceNotice = sourceLibrary.notice }
}
.alert(item: $displayedSourceNotice) { notice in
Alert(
title: Text(notice.title),
message: Text(notice.detail),
dismissButton: .default(Text("好")) { sourceLibrary.notice = nil }
)
}
}
private var requestedDemoPage: String? {
let arguments = ProcessInfo.processInfo.arguments
guard let index = arguments.firstIndex(of: "-uiDemoTab"), arguments.indices.contains(index + 1) else {
return nil
}
return arguments[index + 1]
}
private var automationSearchKeyword: String {
let arguments = ProcessInfo.processInfo.arguments
guard let index = arguments.firstIndex(of: "-uiAutomationSearch"),
arguments.indices.contains(index + 1) else { return "" }
return arguments[index + 1]
}
}
private struct BookshelfView: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel
@State private var showsLocalImporter = false
@State private var pendingLocalURLs: [URL] = []
@State private var selectedGroupID = BookGroup.idAll
@State private var showsGroupManager = false
@AppStorage("bookshelfLayout") private var bookshelfLayout = "grid3"
private var selectedGroup: BookGroup {
library.visibleGroups.first(where: { $0.id == selectedGroupID })
?? library.visibleGroups.first
?? BookGroup(groupId: BookGroup.idAll, groupName: "全部")
}
private var displayedBooks: [Book] { library.books(in: selectedGroup) }
private var recentBook: Book? {
displayedBooks.filter(BookReadingPresentation.hasStarted)
.sorted { $0.durChapterTime > $1.durChapterTime }
.first
}
var body: some View {
ScrollView {
VStack(spacing: 0) {
ReadingPageHeader(title: "书架", subtitle: "阅读,让时间有了形状") {
NavigationLink { SearchView() } label: {
ReadingToolbarIcon(systemName: "magnifyingglass")
}
.accessibilityLabel("搜索")
bookshelfMenu
}
if library.visibleGroups.count > 1 {
bookshelfGroups.padding(.bottom, 24)
}
LazyVStack(alignment: .leading, spacing: 0) {
if displayedBooks.isEmpty {
EmptyBookshelfHero { showsLocalImporter = true }
} else {
if let book = recentBook {
VStack(alignment: .leading, spacing: 12) {
Text("继续阅读")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
NavigationLink {
BookshelfBookDestination(book: book, source: sources.sources.first { $0.bookSourceUrl == book.origin })
} label: {
ContinueReadingCard(book: book)
}
.buttonStyle(.plain)
.accessibilityLabel("继续阅读,\(book.name),\(BookReadingPresentation.position(book))")
.accessibilityIdentifier("bookshelf.continue")
}
.padding(.bottom, 30)
}
bookshelfSummary
if bookshelfLayout.hasPrefix("grid") {
LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 24) {
ForEach(displayedBooks) { book in
BookshelfBookLink(book: book, compact: false, grid: true)
}
}
} else {
ForEach(displayedBooks) { book in
BookshelfBookLink(book: book, compact: bookshelfLayout == "compact", grid: false)
if book.id != displayedBooks.last?.id {
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
.padding(.leading, bookshelfLayout == "compact" ? 54 : 66)
}
}
}
}
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
}
.background(ReadingTheme.canvas)
.navigationBarHidden(true)
.fileImporter(
isPresented: $showsLocalImporter,
allowedContentTypes: [.text, .plainText, .data],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
pendingLocalURLs = urls.filter { $0.pathExtension.lowercased() == "txt" }
if pendingLocalURLs.isEmpty, !urls.isEmpty {
library.reportError(LocalBookError.unsupportedFormat)
}
case .failure(let error): library.reportError(error)
}
}
.sheet(isPresented: Binding(
get: { !pendingLocalURLs.isEmpty },
set: { if !$0 { pendingLocalURLs = [] } }
)) {
LocalBookImportReviewView(urls: pendingLocalURLs) {
pendingLocalURLs = []
}
.environmentObject(library)
}
.sheet(isPresented: $showsGroupManager) {
BookGroupManagerView()
.environmentObject(library)
}
.alert("本地书导入失败", isPresented: Binding(
get: { library.errorMessage != nil },
set: { if !$0 { library.clearError() } }
)) {
Button("好") { library.clearError() }
} message: {
Text(library.errorMessage ?? "未知错误")
}
.alert("书架更新", isPresented: Binding(
get: { library.updateMessage != nil },
set: { if !$0 { library.clearUpdateMessage() } }
)) {
Button("好") { library.clearUpdateMessage() }
} message: {
Text(library.updateMessage ?? "")
}
}
private var bookshelfSummary: some View {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 6) {
Text(selectedGroupID == BookGroup.idAll ? "全部书籍" : selectedGroup.groupName)
.font(.title3.bold())
.accessibilityAddTraits(.isHeader)
Text("\(displayedBooks.count)本")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
HStack(spacing: 0) {
Button { bookshelfLayout = "grid3" } label: {
Image(systemName: "square.grid.2x2.fill")
.foregroundStyle(bookshelfLayout.hasPrefix("grid") ? ReadingTheme.accent : .secondary)
.frame(width: 44, height: 44)
}
.accessibilityLabel("网格布局")
.accessibilityAddTraits(bookshelfLayout.hasPrefix("grid") ? .isSelected : [])
Button { bookshelfLayout = "list" } label: {
Image(systemName: "list.bullet")
.foregroundStyle(bookshelfLayout.hasPrefix("grid") ? .secondary : ReadingTheme.accent)
.frame(width: 44, height: 44)
}
.accessibilityLabel("列表布局")
.accessibilityAddTraits(bookshelfLayout.hasPrefix("grid") ? [] : .isSelected)
}
.font(.system(size: 18, weight: .medium))
.buttonStyle(.plain)
}
.padding(.bottom, 20)
}
@ViewBuilder
private var bookshelfMenu: some View {
if library.isCheckingUpdates {
ProgressView().controlSize(.small).frame(width: 44, height: 44)
} else {
Menu {
Button("更新目录", systemImage: "arrow.clockwise") {
Task { await library.checkForUpdates(sources: sources.sources) }
}
Button("本地书", systemImage: "plus") { showsLocalImporter = true }
Button("书架分组", systemImage: "rectangle.3.group") { showsGroupManager = true }
Menu("书架布局", systemImage: "square.grid.2x2") {
layoutButton("列表", value: "list")
layoutButton("紧凑列表", value: "compact")
ForEach(2...6, id: \.self) { count in
layoutButton("\(count) 列宫格", value: "grid\(count)")
}
}
NavigationLink {
FileManagementView()
} label: {
Label("文件管理", systemImage: "folder")
}
} label: {
ReadingToolbarIcon(systemName: "ellipsis")
}
.accessibilityLabel("更多")
}
}
private var bookshelfGroups: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 24) {
ForEach(library.visibleGroups) { group in
Button(group.groupName) { selectedGroupID = group.id }
.font(.subheadline.weight(selectedGroupID == group.id ? .semibold : .regular))
.foregroundStyle(selectedGroupID == group.id ? Color.primary : .secondary)
.frame(minHeight: 44)
.overlay(alignment: .bottom) {
if selectedGroupID == group.id {
Rectangle().fill(ReadingTheme.accent).frame(height: 2)
}
}
.accessibilityAddTraits(selectedGroupID == group.id ? .isSelected : [])
}
}
.padding(.horizontal, 24)
}
.overlay(alignment: .bottom) {
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
}
}
private var gridColumns: [GridItem] {
let count = Int(bookshelfLayout.dropFirst(4)) ?? 3
let maximum = dynamicTypeSize.isAccessibilitySize ? 2 : 6
return Array(repeating: GridItem(.flexible(), spacing: 16, alignment: .top), count: max(2, min(count, maximum)))
}
@ViewBuilder
private func layoutButton(_ title: String, value: String) -> some View {
Button {
bookshelfLayout = value
} label: {
if bookshelfLayout == value { Label(title, systemImage: "checkmark") }
else { Text(title) }
}
}
}
private struct BookshelfBookLink: View {
@EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel
let book: Book
let compact: Bool
let grid: Bool
@State private var showsGroupSelector = false
private var source: BookSource? { sources.sources.first { $0.bookSourceUrl == book.origin } }
var body: some View {
NavigationLink {
BookshelfBookDestination(book: book, source: source)
} label: {
if grid { BookshelfGridItem(book: book) }
else { BookshelfRow(book: book, compact: compact) }
}
.buttonStyle(.plain)
.contextMenu {
NavigationLink {
if let source { BookDetailView(book: book, source: source) }
else { LocalBookDetailView(book: book) }
} label: {
Label("书籍信息", systemImage: "info.circle")
}
Button("移至分组", systemImage: "rectangle.3.group") { showsGroupSelector = true }
Button("移出书架", systemImage: "trash", role: .destructive) {
Task { await library.remove(bookURL: book.bookUrl) }
}
}
.sheet(isPresented: $showsGroupSelector) {
BookGroupSelectView(book: book).environmentObject(library)
}
}
}
private struct BookshelfGridItem: View {
let book: Book
var body: some View {
VStack(alignment: .leading, spacing: 0) {
GeometryReader { geometry in
BookCoverArtwork(
title: book.name,
author: book.author,
remoteURL: book.coverUrl,
width: geometry.size.width,
height: geometry.size.height
)
}
.aspectRatio(0.70, contentMode: .fit)
.frame(maxWidth: 180)
.frame(maxWidth: .infinity)
.overlay(alignment: .topTrailing) {
if book.lastCheckCount > 0 {
Circle()
.fill(ReadingTheme.coral)
.frame(width: 7, height: 7)
.overlay { Circle().stroke(ReadingTheme.canvas, lineWidth: 2) }
.offset(x: 3, y: -3)
.accessibilityLabel("更新 \(book.lastCheckCount) 章")
}
}
Text(book.name)
.font(.caption.weight(.semibold))
.lineLimit(2)
.padding(.top, 10)
Text(book.author.isEmpty ? "未知作者" : book.author)
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
.padding(.top, 3)
ReadingProgressLine(value: BookReadingPresentation.progress(book))
.padding(.top, 12)
Text(BookReadingPresentation.progressLabel(book))
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
.padding(.top, 7)
}
}
}
private struct BookshelfBookDestination: View {
let book: Book
let source: BookSource?
var body: some View {
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
ReaderView(demoBook: book, chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText)
} else {
BookshelfReadView(book: book, source: source)
}
}
}
private struct ContinueReadingCard: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
let book: Book
var body: some View {
HStack(alignment: .center, spacing: 18) {
VStack(alignment: .leading, spacing: 0) {
Text(book.name)
.font(.title3.bold())
.foregroundStyle(ReadingTheme.ink)
.lineLimit(2)
Text(book.author.isEmpty ? "未知作者" : book.author)
.font(.subheadline).foregroundStyle(.secondary)
.padding(.top, 7)
Text(BookReadingPresentation.position(book))
.font(.caption).foregroundStyle(.secondary)
.lineLimit(2)
.padding(.top, 20)
if book.totalChapterNum > 0 {
HStack(spacing: 10) {
ReadingProgressLine(value: BookReadingPresentation.progress(book))
Text("\(Int((BookReadingPresentation.progress(book) * 100).rounded()))%")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.top, 12)
}
HStack(spacing: 12) {
Text("继续阅读")
Image(systemName: "arrow.right")
}
.font(.caption.weight(.semibold))
.foregroundStyle(ReadingTheme.onAccent)
.padding(.horizontal, 18)
.frame(minHeight: 40)
.background(ReadingTheme.accent, in: Capsule())
.padding(.top, 20)
}
.frame(maxWidth: .infinity, alignment: .leading)
if !dynamicTypeSize.isAccessibilitySize {
BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.coverUrl, width: 94, height: 140)
}
}
.padding(20)
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20))
}
}
private struct BookGroupManagerView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: BookLibraryModel
@State private var newName = ""
@State private var showsCreate = false
@State private var editingGroup: BookGroup?
@State private var editingName = ""
private var customGroups: [BookGroup] {
library.groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order }
}
var body: some View {
ReadingNavigationStack {
List {
Section {
HStack {
Text("全部")
Spacer()
Text("系统分组").font(.caption).foregroundStyle(.secondary)
}
}
Section("自定义分组") {
ForEach(customGroups) { group in
HStack {
Toggle(isOn: Binding(
get: { group.show },
set: { visible in Task { await library.setGroupVisible(group, visible: visible) } }
)) {
Text(group.groupName)
}
Button {
editingGroup = group
editingName = group.groupName
} label: {
Image(systemName: "pencil")
}
.buttonStyle(.borderless)
}
.swipeActions {
Button("删除", role: .destructive) {
Task { await library.deleteGroup(group) }
}
}
}
.onMove { offsets, destination in
Task { await library.moveGroups(from: offsets, to: destination) }
}
}
}
.overlay {
if customGroups.isEmpty {
ReadingUnavailableView("没有自定义分组", systemImage: "rectangle.3.group", description: Text("点右上角加号新建分组。"))
}
}
.navigationTitle("书架分组")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
ToolbarItemGroup(placement: .topBarTrailing) {
EditButton()
Button("新建", systemImage: "plus") { newName = ""; showsCreate = true }
}
}
.alert("新建分组", isPresented: $showsCreate) {
TextField("分组名称", text: $newName)
Button("取消", role: .cancel) {}
Button("确定") { Task { await library.createGroup(name: newName) } }
}
.alert("编辑分组", isPresented: Binding(
get: { editingGroup != nil },
set: { if !$0 { editingGroup = nil } }
)) {
TextField("分组名称", text: $editingName)
Button("取消", role: .cancel) { editingGroup = nil }
Button("确定") {
guard let group = editingGroup else { return }
Task { await library.renameGroup(group, name: editingName) }
editingGroup = nil
}
}
}
}
}
private struct BookGroupSelectView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: BookLibraryModel
let book: Book
@State private var selected: Set<Int64>
init(book: Book) {
self.book = book
_selected = State(initialValue: [])
}
private var customGroups: [BookGroup] {
library.groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order }
}
var body: some View {
ReadingNavigationStack {
List(customGroups) { group in
Button {
if !selected.insert(group.id).inserted { selected.remove(group.id) }
} label: {
HStack {
Text(group.groupName).foregroundStyle(.primary)
Spacer()
if selected.contains(group.id) { Image(systemName: "checkmark").foregroundStyle(ReadingTheme.accent) }
}
}
}
.overlay {
if customGroups.isEmpty {
ReadingUnavailableView("没有自定义分组", systemImage: "rectangle.3.group")
}
}
.navigationTitle("移至分组")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("确定") {
Task { await library.setBook(book, groupIDs: selected); dismiss() }
}
}
}
.onAppear {
selected = Set(customGroups.filter { (book.group & $0.groupId) != 0 }.map(\.groupId))
}
}
.interactiveDismissDisabled()
}
}
private struct LocalBookImportReviewView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: BookLibraryModel
let urls: [URL]
let onFinish: () -> Void
@State private var selectedPaths: Set<String>
@State private var isImporting = false
init(urls: [URL], onFinish: @escaping () -> Void) {
self.urls = urls
self.onFinish = onFinish
_selectedPaths = State(initialValue: Set(urls.map(\.path)))
}
var body: some View {
ReadingNavigationStack {
List(urls, id: \.path) { url in
Button {
if !selectedPaths.insert(url.path).inserted { selectedPaths.remove(url.path) }
} label: {
HStack {
Image(systemName: selectedPaths.contains(url.path) ? "checkmark.square.fill" : "square")
.foregroundStyle(selectedPaths.contains(url.path) ? ReadingTheme.accent : .secondary)
VStack(alignment: .leading) {
Text(url.deletingPathExtension().lastPathComponent).foregroundStyle(.primary)
Text(url.lastPathComponent).font(.caption).foregroundStyle(.secondary)
}
}
}
}
.overlay { if isImporting { ProgressView("正在导入…") } }
.navigationTitle("导入本地书")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { finish() }.disabled(isImporting)
}
ToolbarItem(placement: .topBarTrailing) {
Button(selectedPaths.count == urls.count ? "取消全选" : "全选") {
if selectedPaths.count == urls.count { selectedPaths.removeAll() }
else { selectedPaths = Set(urls.map(\.path)) }
}
.disabled(isImporting)
}
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
isImporting = true
let selected = urls.filter { selectedPaths.contains($0.path) }
Task {
await library.importLocalFiles(selected)
finish()
}
}
.disabled(selectedPaths.isEmpty || isImporting)
}
}
}
.interactiveDismissDisabled(isImporting)
}
private func finish() {
onFinish()
dismiss()
}
}
private struct EmptyBookshelfHero: View {
let onImport: () -> Void
var body: some View {
VStack(spacing: 24) {
Image(systemName: "books.vertical")
.font(.system(size: 40, weight: .ultraLight))
.foregroundStyle(ReadingTheme.accent)
.accessibilityHidden(true)
VStack(spacing: 10) {
Text("书架还是空的").font(.title3.weight(.semibold))
Text("导入本地书,或添加书源后搜索书籍。")
.font(.subheadline).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
VStack(spacing: 12) {
Button("导入本地书", action: onImport)
.buttonStyle(.borderedProminent)
.controlSize(.large)
NavigationLink("添加书源") { SourceManagementView() }
.font(.subheadline)
.frame(minHeight: 44)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 80)
}
}
struct BookshelfReadView: View {
let book: Book
let source: BookSource?
@StateObject private var model = BookshelfReadViewModel()
var body: some View {
Group {
if !model.chapters.isEmpty, let resolvedBook = model.resolvedBook {
if let source {
ReaderView(
book: resolvedBook,
chapters: model.chapters,
initialIndex: resolvedBook.durChapterIndex,
source: source
)
} else {
ReaderView(book: resolvedBook, chapters: model.chapters, initialIndex: resolvedBook.durChapterIndex)
}
} else if model.isLoading {
ProgressView("正在打开…")
} else if let errorMessage = model.errorMessage {
ReadingUnavailableView("无法打开书籍", systemImage: "book.closed", description: Text(errorMessage))
} else {
ProgressView("正在打开…")
}
}
.task { await model.load(book: book, source: source) }
}
}
private struct BookshelfRow: View {
let book: Book
var compact = false
@ScaledMetric(relativeTo: .body) private var coverWidth = 52
private var readingPosition: String {
if let title = book.durChapterTitle, !title.isEmpty { return title }
if book.durChapterIndex > 0 || book.durChapterPos > 0 || book.durChapterTime > 0 {
return book.totalChapterNum > 0
? "第 \(book.durChapterIndex + 1) 章 / 共 \(book.totalChapterNum) 章"
: "已读至第 \(book.durChapterIndex + 1) 章"
}
return "尚未阅读"
}
var body: some View {
HStack(alignment: .center, spacing: 14) {
BookCoverArtwork(
title: book.name,
author: book.author,
remoteURL: book.coverUrl,
width: compact ? 40 : coverWidth,
height: compact ? 56 : coverWidth * 1.42
)
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(book.name)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
.lineLimit(2)
Spacer(minLength: 0)
if book.lastCheckCount > 0 {
Text("+\(book.lastCheckCount)")
.font(.caption.monospacedDigit())
.foregroundStyle(ReadingTheme.accent)
.fixedSize()
.accessibilityLabel("更新 \(book.lastCheckCount) 章")
}
}
Text(book.author.isEmpty ? "未知作者" : book.author)
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
if !compact {
Text(readingPosition)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, compact ? 10 : 12)
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityValue(book.latestChapterTitle.map { "最新章节:\($0)" } ?? "")
}
}
struct SearchView: View {
@EnvironmentObject private var library: SourceLibraryModel
let fixedSource: BookSource?
private let initialKeyword: String
@State private var keyword: String
@StateObject private var model = SearchViewModel()
@State private var showsScope = false
@State private var selectedGroups = Set<String>()
@State private var selectedSourceID: String?
@State private var didStartInitialSearch = false
@State private var searchIsPresented = false
@AppStorage("precisionSearch") private var precisionSearch = false
init(fixedSource: BookSource? = nil, initialKeyword: String = "") {
self.fixedSource = fixedSource
self.initialKeyword = initialKeyword
_keyword = State(initialValue: initialKeyword)
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
switch model.state {
case .idle:
idleContent
case .searching where model.books.isEmpty:
initialLoadingView
case .unavailable:
unavailableView
case .empty:
emptyResultsView
case .searching, .finished:
resultsContent
}
if model.state != .searching, !model.diagnostics.isEmpty { diagnosticsCard }
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 28)
}
.background(ReadingTheme.canvas)
.navigationTitle("搜索")
.readingSearchable(text: $keyword, isPresented: $searchIsPresented, prompt: "书名或作者")
.readingScrollDismissesKeyboard()
.onSubmit(of: [.text, .search]) { startSearch() }
.onDisappear {
if model.state == .searching || model.isLoadingMore { model.cancel() }
}
.onChange(of: keyword) { value in
if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
model.cancel(reset: true)
}
}
.onChange(of: library.sources.count) { _ in
guard !didStartInitialSearch,
!initialKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!library.sources.isEmpty else { return }
didStartInitialSearch = true
startSearch()
}
.task {
await model.loadSearchKeywords()
if initialKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
searchIsPresented = true
} else if !library.sources.isEmpty, !didStartInitialSearch {
didStartInitialSearch = true
startSearch()
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle("精准搜索", isOn: $precisionSearch)
Button("分组或书源", systemImage: "scope") { showsScope = true }
NavigationLink {
SourceManagementView()
} label: {
Label("书源管理", systemImage: "network")
}
if model.state == .searching {
Button("停止搜索", systemImage: "stop.fill", role: .destructive) {
model.cancel()
}
} else if model.isPaused {
Button("继续搜索", systemImage: "play.fill") { model.resume() }
}
} label: {
Label("搜索选项", systemImage: "slider.horizontal.3")
}
}
}
.sheet(isPresented: $showsScope) {
SearchScopeSheet(
sources: library.sources.filter(\.enabled),
groups: library.groups,
selectedGroups: $selectedGroups,
selectedSourceID: $selectedSourceID
)
}
}
@ViewBuilder
private var idleContent: some View {
discoveryPrompt
if !model.searchKeywords.isEmpty {
searchHistoryView
}
}
private var discoveryPrompt: some View {
Button { searchIsPresented = true } label: {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.body)
.foregroundStyle(ReadingTheme.accent)
VStack(alignment: .leading, spacing: 6) {
Text("搜索书名或作者")
.font(.headline.weight(.medium))
.foregroundStyle(.primary)
Text("\(searchableSourceCount) 个书源 · \(scopeDisplay)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.vertical, 20)
}
.buttonStyle(.plain)
}
private var initialLoadingView: some View {
VStack(spacing: 12) {
ReadingCard {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
ProgressView().controlSize(.regular)
VStack(alignment: .leading, spacing: 3) {
Text("正在搜索“\(model.submittedKeyword)”").font(.headline)
Text(searchProgressText).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button("停止", role: .destructive) { model.cancel() }
.font(.subheadline.weight(.medium))
}
ProgressView(value: searchProgress)
.tint(ReadingTheme.accent)
Text("结果会在书源返回后立即显示,无需等待全部完成。")
.font(.caption).foregroundStyle(.secondary)
}
}
ForEach(0..<2, id: \.self) { _ in SearchLoadingRow() }
}
}
private var resultsContent: some View {
Group {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("“\(model.submittedKeyword)”").font(.headline).lineLimit(1)
Text(model.isShowingCachedResults
? (model.state == .searching ? "已显示缓存结果,正在更新书源" : "已显示缓存结果")
: "找到 \(model.books.count) 本,已自动合并重复来源")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
if model.isPaused {
Button("继续", systemImage: "play.fill") { model.resume() }
.buttonStyle(.bordered)
.controlSize(.small)
} else if model.state == .searching {
StatusPill(text: "\(model.completedSourceCount)/\(model.totalSourceCount)", systemImage: "magnifyingglass")
} else {
StatusPill(text: scopeDisplay, systemImage: "scope")
}
}
if model.state == .searching {
ProgressView(value: searchProgress).tint(ReadingTheme.accent)
}
}
.padding(.vertical, 4)
ForEach(model.books) { book in
if let source = library.sources.first(where: { $0.bookSourceUrl == book.origin }) {
NavigationLink {
BookDetailView(searchBook: book, source: source)
} label: {
SearchBookRow(book: book)
}
.buttonStyle(.plain)
.onAppear { loadMoreIfNeeded(book) }
} else {
SearchBookRow(book: book)
.onAppear { loadMoreIfNeeded(book) }
}
}
if model.isLoadingMore {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text("正在查找更多结果…").font(.subheadline).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
} else if !model.hasMore, model.state == .finished {
Text("已经到底了")
.font(.caption).foregroundStyle(.tertiary)
.frame(maxWidth: .infinity).padding(.vertical, 8)
}
}
}
private var emptyResultsView: some View {
SearchStateCard(
title: model.isPaused ? "搜索已停止" : "没有找到“\(model.submittedKeyword)”",
detail: emptyResultsDetail,
systemImage: model.isPaused ? "pause.circle" : "book.closed"
) {
if model.isPaused {
Button("继续搜索", systemImage: "play.fill") { model.resume() }
.buttonStyle(.borderedProminent).tint(ReadingTheme.accent)
}
Button("修改关键词", systemImage: "text.cursor") { searchIsPresented = true }
.buttonStyle(.bordered)
}
}
private var unavailableView: some View {
SearchStateCard(
title: "当前范围没有可搜索书源",
detail: "请启用带搜索规则的书源,或调整当前搜索范围。",
systemImage: "network.slash"
) {
Button("调整范围", systemImage: "scope") { showsScope = true }
.buttonStyle(.borderedProminent).tint(ReadingTheme.accent)
NavigationLink {
SourceManagementView()
} label: {
Label("管理书源", systemImage: "network")
}
.buttonStyle(.bordered)
}
}
private var searchProgress: Double {
guard model.totalSourceCount > 0 else { return 0 }
return Double(model.completedSourceCount) / Double(model.totalSourceCount)
}
private var emptyResultsDetail: String {
if model.isPaused { return "你可以继续搜索剩余书源,或修改关键词重新搜索。" }
if precisionSearch { return "试试更短的书名、作者名,或关闭精准搜索。" }
return "试试更短的书名、作者名,或调整搜索范围。"
}
private var searchProgressText: String {
model.completedSourceCount == 0
? "正在连接 \(model.totalSourceCount) 个书源"
: "已完成 \(model.completedSourceCount) / \(model.totalSourceCount) 个书源"
}
private var searchableSourceCount: Int {
scopedSources.filter { $0.enabled && $0.searchUrl?.isEmpty == false }.count
}
private func loadMoreIfNeeded(_ book: SearchBook) {
if model.state != .searching, book.id == model.books.last?.id { model.loadMore() }
}
private var diagnosticsCard: some View {
DisclosureGroup("部分书源未返回结果(\(model.diagnostics.count))") {
VStack(alignment: .leading, spacing: 10) {
ForEach(model.diagnostics.prefix(20)) { diagnostic in
HStack(alignment: .top) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text(diagnostic.message).font(.subheadline)
Text(diagnostic.code).font(.caption.monospaced()).foregroundStyle(.secondary)
}
}
}
}
.padding(.top, 10)
}
.padding(16)
.background(.background, in: RoundedRectangle(cornerRadius: 18))
}
private var searchHistoryView: some View {
ReadingCard {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("搜索历史").font(.headline)
Spacer()
Button("清空") { Task { await model.clearSearchKeywords() } }.font(.subheadline)
}
.padding(.bottom, 8)
ForEach(model.searchKeywords) { item in
Divider().padding(.leading, 28)
HStack(spacing: 10) {
Button { keyword = item.word; startSearch() } label: {
HStack(spacing: 10) {
Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)
Text(item.word).foregroundStyle(.primary).lineLimit(1)
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
Task { await model.deleteSearchKeyword(item.word) }
} label: {
Image(systemName: "xmark").font(.caption.bold()).foregroundStyle(.tertiary)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
.accessibilityLabel("删除 \(item.word)")
}
.padding(.vertical, 7)
}
}
}
}
private var scopedSources: [BookSource] {
if var fixedSource { fixedSource.enabled = true; return [fixedSource] }
let enabled = library.sources.filter(\.enabled)
if let selectedSourceID { return enabled.filter { $0.id == selectedSourceID } }
guard !selectedGroups.isEmpty else { return enabled }
return enabled.filter { source in selectedGroups.contains(where: source.hasGroup) }
}
private var scopeDisplay: String {
if let fixedSource { return fixedSource.bookSourceName }
if let selectedSourceID, let source = library.sources.first(where: { $0.id == selectedSourceID }) { return source.bookSourceName }
return selectedGroups.isEmpty ? "全部书源" : selectedGroups.sorted().joined(separator: ",")
}
private func startSearch() {
let value = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
searchIsPresented = false
model.search(keyword: value, sources: scopedSources, precision: precisionSearch)
}
}
private struct SearchLoadingRow: View {
var body: some View {
ReadingCard {
HStack(spacing: 15) {
RoundedRectangle(cornerRadius: 8).fill(.secondary.opacity(0.12)).frame(width: 58, height: 82)
VStack(alignment: .leading, spacing: 10) {
RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.14)).frame(width: 150, height: 14)
RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.1)).frame(width: 90, height: 11)
RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.08)).frame(maxWidth: .infinity).frame(height: 11)
}
}
}
.accessibilityHidden(true)
}
}
private struct SearchStateCard<Actions: View>: View {
let title: String
let detail: String
let systemImage: String
@ViewBuilder let actions: Actions
var body: some View {
ReadingCard {
VStack(spacing: 14) {
Image(systemName: systemImage)
.font(.system(size: 32))
.foregroundStyle(ReadingTheme.accent)
.frame(width: 64, height: 64)
.background(ReadingTheme.accentSoft.opacity(0.5), in: Circle())
VStack(spacing: 5) {
Text(title).font(.headline).multilineTextAlignment(.center)
Text(detail).font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
HStack(spacing: 10) { actions }
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
}
}
private struct SearchBookRow: View {
let book: SearchBook
var body: some View {
HStack(alignment: .top, spacing: 14) {
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(.primary).lineLimit(2)
Text(book.author.isEmpty ? "未知作者" : book.author)
.font(.caption).foregroundStyle(.secondary)
if let latest = book.latestChapterTitle, !latest.isEmpty {
Text(latest)
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
HStack(spacing: 6) {
Text(book.origins.count > 1 ? "\(book.origins.count) 个来源" : book.originName)
.lineLimit(1)
if let wordCount = book.wordCount, !wordCount.isEmpty {
Text("·")
Text(wordCount).lineLimit(1)
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) {
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
.padding(.leading, 66)
}
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
}
}
private struct SettingsView: View {
@Environment(\.colorScheme) private var colorScheme
@AppStorage("themeMode") private var themeMode = "0"
@ScaledMetric(relativeTo: .body) private var rowHeight = 52
var body: some View {
GeometryReader { geometry in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
profileHeader(topInset: geometry.safeAreaInsets.top)
VStack(alignment: .leading, spacing: 24) {
appCard
readingSection
rulesSection
settingSection("文件与存储") {
settingLink("文件管理", icon: "folder", showsSeparator: false) {
FileManagementView()
}
}
supportSection
}
.padding(.horizontal, 28)
.padding(.bottom, 32)
}
.background(alignment: .top) {
Image("ProfileHeader")
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: geometry.safeAreaInsets.top + 156)
.clipped()
.overlay {
if colorScheme == .dark {
Color.black.opacity(0.62)
}
}
.overlay(alignment: .bottom) {
LinearGradient(
colors: [ProfileTheme.canvas.opacity(0), ProfileTheme.canvas],
startPoint: .top, endPoint: .bottom
)
.frame(height: 44)
}
.accessibilityHidden(true)
}
}
.background(ProfileTheme.canvas)
.ignoresSafeArea(.container, edges: .top)
.accessibilityIdentifier("profile.scroll")
}
.background(ProfileTheme.canvas)
.navigationBarHidden(true)
}
private func profileHeader(topInset: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("我的")
.font(.largeTitle.bold())
.foregroundStyle(ProfileTheme.ink)
.accessibilityAddTraits(.isHeader)
Text("按照你的方式阅读")
.font(.subheadline)
.foregroundStyle(ProfileTheme.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28)
.padding(.top, topInset + 42)
.padding(.bottom, 34)
}
private var appCard: some View {
NavigationLink {
AboutView()
} label: {
HStack(spacing: 16) {
Image("BrandIcon")
.resizable()
.scaledToFit()
.frame(width: 58, height: 58)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 6) {
Text(AppIdentity.name)
.font(.headline)
.foregroundStyle(ProfileTheme.ink)
.fixedSize(horizontal: false, vertical: true)
Text("快速搜书,自由阅读")
.font(.footnote)
.foregroundStyle(ProfileTheme.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(ProfileTheme.ink.opacity(0.75))
.frame(width: 26, height: 26)
.background(ProfileTheme.detail.opacity(0.10), in: Circle())
.accessibilityHidden(true)
}
.padding(14)
.frame(maxWidth: .infinity, minHeight: 86, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(LinearGradient(
colors: [ProfileTheme.surface, ProfileTheme.appCardTint],
startPoint: .topLeading, endPoint: .bottomTrailing
))
}
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(ProfileTheme.surface.opacity(0.9), lineWidth: 0.75)
}
.shadow(color: .black.opacity(0.035), radius: 18, x: 0, y: 8)
}
.buttonStyle(.plain)
.accessibilityElement(children: .combine)
.accessibilityHint("查看关于与版本信息")
.accessibilityIdentifier("profile.about")
}
private var readingSection: some View {
settingSection("阅读") {
settingLink("阅读设置", icon: "textformat.size") { ReadingPreferencesView() }
Menu {
Picker("外观", selection: $themeMode) {
Text("跟随系统").tag("0")
Text("浅色").tag("1")
Text("深色").tag("2")
Text("E-Ink 模式").tag("3")
}
} label: {
settingRow("外观", icon: "circle.lefthalf.filled", value: appearanceTitle, showsValuePill: true)
}
.buttonStyle(.plain)
.accessibilityLabel("外观")
.accessibilityValue(appearanceTitle)
.accessibilityIdentifier("profile.appearance")
settingLink("在线朗读", icon: "waveform") { HttpTTSManagementView() }
settingLink("书签", icon: "bookmark") { AllBookmarksView() }
settingLink("阅读记录", icon: "clock", showsSeparator: false) {
ReadingHistoryView()
}
}
}
private var rulesSection: some View {
settingSection("书源与规则") {
settingLink("书源管理", icon: "globe") { SourceManagementView() }
settingLink("规则订阅", icon: "link") { RuleSubscriptionView() }
settingLink("替换净化", icon: "text.badge.checkmark") { ReplaceRuleManagementView() }
settingLink("TXT 目录规则", icon: "list.bullet.rectangle", showsSeparator: false) {
TxtTocRuleManagementView()
}
}
}
private var supportSection: some View {
settingSection("项目与支持") {
Link(destination: AppIdentity.authorURL) {
settingRow("开发者 Aminark", icon: "person.crop.circle", isExternal: true)
}
.buttonStyle(.plain)
Link(destination: AppIdentity.projectURL) {
settingRow("GitHub 项目", icon: "chevron.left.forwardslash.chevron.right", isExternal: true)
}
.buttonStyle(.plain)
settingLink("其他设置", icon: "gearshape") { OtherSettingsView() }
settingLink("关于", icon: "info.circle", showsSeparator: false) { AboutView() }
}
}
private var appearanceTitle: String {
switch themeMode {
case "1": "浅色"
case "2": "深色"
case "3": "E-Ink 模式"
default: "跟随系统"
}
}
private func settingSection<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.body)
.foregroundStyle(ProfileTheme.secondary)
.accessibilityAddTraits(.isHeader)
VStack(spacing: 0, content: content)
.background(ProfileTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.shadow(color: .black.opacity(0.025), radius: 20, x: 0, y: 8)
}
}
private func settingLink<Destination: View>(
_ title: String,
icon: String,
showsSeparator: Bool = true,
@ViewBuilder destination: () -> Destination
) -> some View {
NavigationLink(destination: destination) {
settingRow(title, icon: icon, showsSeparator: showsSeparator)
}
.buttonStyle(.plain)
.accessibilityIdentifier("profile.\(icon)")
}
private func settingRow(
_ title: String,
icon: String,
value: String? = nil,
showsValuePill: Bool = false,
isExternal: Bool = false,
showsSeparator: Bool = true
) -> some View {
HStack(spacing: 18) {
ProfileSettingIcon(systemName: icon)
.frame(width: 26, height: 24)
.accessibilityHidden(true)
Text(title)
.font(.body)
.foregroundStyle(ProfileTheme.ink)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
if let value {
Text(value)
.font(.footnote)
.foregroundStyle(ProfileTheme.secondary)
.multilineTextAlignment(.trailing)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, showsValuePill ? 10 : 0)
.padding(.vertical, showsValuePill ? 4 : 0)
.overlay {
if showsValuePill {
Capsule().strokeBorder(ProfileTheme.separator, lineWidth: 0.5)
}
}
}
Image(systemName: isExternal ? "arrow.up.right" : "chevron.right")
.font(.caption.weight(.medium))
.foregroundStyle(ProfileTheme.secondary.opacity(0.65))
.accessibilityHidden(true)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.frame(minHeight: rowHeight)
.contentShape(Rectangle())
.overlay(alignment: .bottom) {
if showsSeparator {
Rectangle()
.fill(ProfileTheme.separator)
.frame(height: 0.5)
.padding(.leading, 62)
.padding(.trailing, 18)
.accessibilityHidden(true)
}
}
.accessibilityElement(children: .combine)
}
}
private enum ProfileTheme {
static let canvas = adaptive(0xF8F8F7, dark: 0x141416)
static let surface = adaptive(0xFFFFFF, dark: 0x232326)
static let appCardTint = adaptive(0xFBF3EC, dark: 0x302923)
static let ink = adaptive(0x171817, dark: 0xF3F0EC)
static let secondary = adaptive(0x898A8C, dark: 0xA5A2A0)
static let detail = adaptive(0xBA916B, dark: 0xD1AB84)
static let separator = adaptive(0xEAEAEA, dark: 0x373638)
private static func adaptive(_ light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { traits in
let hex = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(
red: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255,
alpha: 1
)
})
}
}
private struct ProfileSettingIcon: View {
let systemName: String
var body: some View {
Group {
if systemName == "textformat.size" {
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text("A").font(.system(size: 24, weight: .light)).foregroundStyle(ProfileTheme.ink)
Text("A").font(.system(size: 13, weight: .medium)).foregroundStyle(ProfileTheme.detail)
}
} else if ["circle.lefthalf.filled", "waveform", "bookmark", "clock", "globe"].contains(systemName) {
Canvas { context, size in
context.scaleBy(x: size.width / 24, y: size.height / 24)
draw(in: &context)
}
.frame(width: 22, height: 22)
} else {
Image(systemName: systemName)
.font(.system(size: 20, weight: .regular))
.symbolRenderingMode(.palette)
.foregroundStyle(ProfileTheme.ink, ProfileTheme.detail)
}
}
}
private func draw(in context: inout GraphicsContext) {
let stroke = StrokeStyle(lineWidth: 1.65, lineCap: .round, lineJoin: .round)
let circle = Path(ellipseIn: CGRect(x: 2.5, y: 2.5, width: 19, height: 19))
switch systemName {
case "circle.lefthalf.filled":
var half = Path()
half.addArc(center: CGPoint(x: 12, y: 12), radius: 9.5, startAngle: .degrees(90), endAngle: .degrees(270), clockwise: false)
half.closeSubpath()
context.fill(half, with: .color(ProfileTheme.ink))
context.stroke(circle, with: .color(ProfileTheme.ink), style: stroke)
var edge = Path()
edge.addArc(center: CGPoint(x: 12, y: 12), radius: 10.2, startAngle: .degrees(105), endAngle: .degrees(255), clockwise: false)
context.stroke(edge, with: .color(ProfileTheme.detail), style: StrokeStyle(lineWidth: 1, lineCap: .round))
case "waveform":
for (index, height) in [4.0, 12.0, 21.0, 14.0, 5.0].enumerated() {
let bar = CGRect(x: 2 + Double(index) * 4.4, y: (24 - height) / 2, width: 2.4, height: height)
context.fill(Path(roundedRect: bar, cornerRadius: 1.2), with: .color(index > 2 ? ProfileTheme.detail : ProfileTheme.ink))
}
case "bookmark":
var bookmark = Path()
bookmark.move(to: CGPoint(x: 5, y: 21))
bookmark.addLine(to: CGPoint(x: 5, y: 4))
bookmark.addQuadCurve(to: CGPoint(x: 7, y: 2), control: CGPoint(x: 5, y: 2))
bookmark.addLine(to: CGPoint(x: 17, y: 2))
bookmark.addQuadCurve(to: CGPoint(x: 19, y: 4), control: CGPoint(x: 19, y: 2))
bookmark.addLine(to: CGPoint(x: 19, y: 21))
bookmark.addLine(to: CGPoint(x: 12, y: 16))
bookmark.closeSubpath()
context.stroke(bookmark, with: .color(ProfileTheme.detail), style: stroke)
case "clock":
context.stroke(circle, with: .color(ProfileTheme.ink), style: stroke)
var hands = Path()
hands.move(to: CGPoint(x: 12, y: 6))
hands.addLine(to: CGPoint(x: 12, y: 12))
hands.addLine(to: CGPoint(x: 16, y: 15))
context.stroke(hands, with: .color(ProfileTheme.detail), style: stroke)
case "globe":
let meridian = Path(ellipseIn: CGRect(x: 7.5, y: 2.5, width: 9, height: 19))
context.stroke(meridian, with: .color(ProfileTheme.detail), style: stroke)
var parallels = Path()
for y in [7.0, 17.0] {
parallels.move(to: CGPoint(x: 4, y: y))
parallels.addQuadCurve(to: CGPoint(x: 20, y: y), control: CGPoint(x: 12, y: 12))
}
context.stroke(parallels, with: .color(ProfileTheme.detail), style: stroke)
context.stroke(circle, with: .color(ProfileTheme.ink), style: stroke)
default: break
}
}
}
private struct AllBookmarksView: View {
@EnvironmentObject private var activity: ReadingActivityModel
@EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel
@State private var editingBookmark: Bookmark?
@State private var exportsJSON = false
@State private var exportsMarkdown = false
private var sections: [BookmarkSection] {
var output: [BookmarkSection] = []
for bookmark in activity.bookmarks {
let id = bookmark.bookName + "\u{0}" + bookmark.bookAuthor
if output.last?.id == id {
output[output.count - 1].bookmarks.append(bookmark)
} else {
output.append(.init(id: id, bookName: bookmark.bookName, author: bookmark.bookAuthor, bookmarks: [bookmark]))
}
}
return output
}
var body: some View {
List {
if activity.bookmarks.isEmpty {
ReadingUnavailableView(
"暂无书签",
systemImage: "bookmark.slash",
description: Text("阅读时可从菜单或点击区域添加书签。")
)
} else {
ForEach(sections) { section in
Section("\(section.bookName)(\(section.author))") {
ForEach(section.bookmarks) { bookmark in
bookmarkLink(bookmark)
.contextMenu {
Button("编辑书签", systemImage: "square.and.pencil") { editingBookmark = bookmark }
}
}
}
}
}
}
.navigationTitle("书签")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if !activity.bookmarks.isEmpty {
Menu("导出", systemImage: "square.and.arrow.up") {
Button("导出 JSON") { exportsJSON = true }
Button("导出 Markdown") { exportsMarkdown = true }
}
}
}
}
.sheet(item: $editingBookmark) { bookmark in
BookmarkEditorView(bookmark: bookmark)
}
.fileExporter(
isPresented: $exportsJSON,
document: BookmarkExportDocument(data: jsonExportData),
contentType: .json,
defaultFilename: exportFilename(extension: "json")
) { _ in }
.fileExporter(
isPresented: $exportsMarkdown,
document: BookmarkExportDocument(data: Data(markdownExport.utf8)),
contentType: .plainText,
defaultFilename: exportFilename(extension: "md")
) { _ in }
}
private func matchingBook(_ bookmark: Bookmark) -> Book? {
library.books.first { $0.name == bookmark.bookName && $0.author == bookmark.bookAuthor }
}
@ViewBuilder private func bookmarkLink(_ bookmark: Bookmark) -> some View {
if let book = matchingBook(bookmark) {
NavigationLink {
BookmarkReaderDestination(
book: book,
bookmark: bookmark,
source: sources.sources.first { $0.bookSourceUrl == book.origin }
)
} label: { bookmarkRow(bookmark) }
} else {
Button { editingBookmark = bookmark } label: { bookmarkRow(bookmark) }
}
}
private func bookmarkRow(_ bookmark: Bookmark) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(bookmark.chapterName.isEmpty ? "第 \(bookmark.chapterIndex + 1) 章" : bookmark.chapterName)
.font(.headline).foregroundStyle(ReadingTheme.coral)
if !bookmark.bookText.isEmpty {
Text(bookmark.bookText).font(.footnote).foregroundStyle(.secondary).lineLimit(3)
}
if !bookmark.content.isEmpty {
Label(bookmark.content, systemImage: "note.text").font(.caption).lineLimit(2)
}
}
.padding(.vertical, 5)
}
private var jsonExportData: Data {
(try? JSONEncoder().encode(activity.bookmarks)) ?? Data("[]".utf8)
}
private var markdownExport: String {
var output = ""
var name = ""
var author = ""
for bookmark in activity.bookmarks {
if bookmark.bookName != name && bookmark.bookAuthor != author {
name = bookmark.bookName
author = bookmark.bookAuthor
output += "## \(bookmark.bookName) \(bookmark.bookAuthor)\n\n"
}
output += "#### \(bookmark.chapterName)\n\n"
output += "###### 原文\n \(bookmark.bookText)\n\n"
output += "###### 摘要\n \(bookmark.content)\n\n"
}
return output
}
private func exportFilename(extension value: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyMMddHHmmss"
return "bookmark-\(formatter.string(from: Date())).\(value)"
}
}
private struct BookmarkSection: Identifiable {
let id: String
let bookName: String
let author: String
var bookmarks: [Bookmark]
}
private struct BookmarkExportDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json, .plainText] }
let data: Data
init(data: Data) { self.data = data }
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) }
}
private struct BookmarkEditorView: View {
@EnvironmentObject private var activity: ReadingActivityModel
@Environment(\.dismiss) private var dismiss
let bookmark: Bookmark
@State private var bookText: String
@State private var content: String
init(bookmark: Bookmark) {
self.bookmark = bookmark
_bookText = State(initialValue: bookmark.bookText)
_content = State(initialValue: bookmark.content)
}
var body: some View {
ReadingNavigationStack {
Form {
Section("原文") { TextEditor(text: $bookText).frame(minHeight: 150) }
Section("摘要") { TextEditor(text: $content).frame(minHeight: 120) }
Section {
Button("删除书签", role: .destructive) {
Task { await activity.removeBookmark(bookmark); dismiss() }
}
}
}
.navigationTitle(bookmark.chapterName.isEmpty ? "编辑书签" : bookmark.chapterName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("保存") {
var updated = bookmark
updated.bookText = bookText
updated.content = content
Task { await activity.updateBookmark(updated); dismiss() }
}
}
}
}
}
}
private struct BookmarkReaderDestination: View {
let book: Book
let bookmark: Bookmark
let source: BookSource?
@State private var chapters: [BookChapter] = []
@State private var preparedBook: Book?
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
Group {
if let preparedBook, !chapters.isEmpty {
if book.origin == "local" {
ReaderView(book: preparedBook, chapters: chapters, initialIndex: preparedBook.durChapterIndex)
} else if let source {
ReaderView(book: preparedBook, chapters: chapters, initialIndex: preparedBook.durChapterIndex, source: source)
} else {
ReadingUnavailableView("书源不可用", systemImage: "network.slash", description: Text("请先恢复或启用这本书对应的书源。"))
}
} else if isLoading {
ProgressView("正在恢复书签位置…")
} else {
ReadingUnavailableView(
"无法打开书签",
systemImage: "bookmark.slash",
description: Text(errorMessage ?? "当前目录中找不到书签对应章节。")
)
}
}
.navigationTitle(bookmark.chapterName.isEmpty ? book.name : bookmark.chapterName)
.navigationBarTitleDisplayMode(.inline)
.task { await load() }
}
private func load() async {
guard isLoading, chapters.isEmpty else { return }
let loaded: [BookChapter]
if book.origin == "local" {
let targetBook = book
do {
loaded = try await Task.detached {
try LocalBookService.live().loadChapters(book: targetBook).chapters
}.value
} catch {
errorMessage = error.localizedDescription
isLoading = false
return
}
} else {
guard let source else {
errorMessage = "书源已删除或未启用。"
isLoading = false
return
}
let coordinator = Self.chapterCoordinator()
let cached = await coordinator.storedChapters(bookURL: book.bookUrl)
if cached.isEmpty {
let report = await coordinator.refresh(book: book, source: source)
loaded = report.chapters
if loaded.isEmpty { errorMessage = report.diagnostics.first?.message ?? "目录加载失败。" }
} else {
loaded = cached
}
}
guard let target = targetIndex(in: loaded) else {
isLoading = false
return
}
var restored = book
restored.durChapterIndex = target
restored.durChapterPos = max(bookmark.chapterPosition, 0)
restored.durChapterTitle = loaded[target].title
chapters = loaded
preparedBook = restored
isLoading = false
}
private func targetIndex(in chapters: [BookChapter]) -> Int? {
bookmark.resolvedChapterIndex(in: chapters) ?? chapters.firstIndex(where: { !$0.isVolume })
}
private static func chapterCoordinator() -> BookUpdateCoordinator {
BookUpdateCoordinator(repository: AppBookChapterRepository.live())
}
}
private struct SearchScopeSheet: View {
@Environment(\.dismiss) private var dismiss
let sources: [BookSource]
let groups: [String]
@Binding var selectedGroups: Set<String>
@Binding var selectedSourceID: String?
@State private var mode = 0
@State private var sourceFilter = ""
@State private var filterIsPresented = false
@State private var workingGroups: Set<String>
@State private var workingSourceID: String?
init(
sources: [BookSource],
groups: [String],
selectedGroups: Binding<Set<String>>,
selectedSourceID: Binding<String?>
) {
self.sources = sources
self.groups = groups
_selectedGroups = selectedGroups
_selectedSourceID = selectedSourceID
_workingGroups = State(initialValue: selectedGroups.wrappedValue)
_workingSourceID = State(initialValue: selectedSourceID.wrappedValue)
_mode = State(initialValue: selectedSourceID.wrappedValue == nil ? 0 : 1)
}
var body: some View {
ReadingNavigationStack {
List {
Picker("范围类型", selection: $mode) {
Text("分组").tag(0)
Text("书源").tag(1)
}
.pickerStyle(.segmented)
Button("全部书源") {
selectedGroups.removeAll()
selectedSourceID = nil
dismiss()
}
if mode == 0 {
ForEach(groups, id: \.self) { group in
Button {
workingSourceID = nil
if !workingGroups.insert(group).inserted { workingGroups.remove(group) }
} label: {
HStack {
Text(group).foregroundStyle(.primary)
Spacer()
if workingGroups.contains(group) { Image(systemName: "checkmark") }
}
}
}
} else {
ForEach(filteredSources) { source in
Button {
workingGroups.removeAll()
workingSourceID = source.id
filterIsPresented = false
} label: {
HStack {
VStack(alignment: .leading) {
Text(source.bookSourceName).foregroundStyle(.primary)
Text(source.bookSourceUrl).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if workingSourceID == source.id { Image(systemName: "checkmark") }
}
}
}
}
}
.navigationTitle("分组或书源")
.navigationBarTitleDisplayMode(.inline)
.readingSearchable(text: $sourceFilter, isPresented: $filterIsPresented, prompt: "筛选书源")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("确定") {
selectedGroups = workingGroups
selectedSourceID = workingSourceID
dismiss()
}
}
}
}
}
private var filteredSources: [BookSource] {
guard !sourceFilter.isEmpty else { return sources }
return sources.filter {
$0.bookSourceName.localizedCaseInsensitiveContains(sourceFilter)
|| $0.bookSourceUrl.localizedCaseInsensitiveContains(sourceFilter)
}
}
}
private struct ReadingPreferencesView: View {
@AppStorage("textSize") private var fontSize = 20.0
@AppStorage("letterSpacing") private var letterSpacing = 0.1
@AppStorage("lineSpacingExtra") private var lineSpacing = 12.0
@AppStorage("paragraphSpacing") private var paragraphSpacing = 2.0
@AppStorage("paragraphIndent") private var paragraphIndent = "  "
@AppStorage("paddingLeft") private var paddingLeft = 16.0
@AppStorage("paddingRight") private var paddingRight = 16.0
@AppStorage("paddingTop") private var paddingTop = 6.0
@AppStorage("paddingBottom") private var paddingBottom = 6.0
@AppStorage("textBold") private var fontWeight = 0
@AppStorage("titleMode") private var titleMode = 0
@AppStorage("titleSize") private var titleSize = 0.0
@AppStorage("titleTopSpacing") private var titleTopSpacing = 0.0
@AppStorage("titleBottomSpacing") private var titleBottomSpacing = 0.0
@AppStorage("underlineMode") private var underlineMode = 0
@AppStorage("readerTheme") private var theme = "wechat"
@AppStorage("pageAnim") private var pageAnimation = 0
@AppStorage("readerKeepScreenOn") private var keepScreenOn = false
@AppStorage("readerHideHomeIndicator") private var hideHomeIndicator = false
var body: some View {
Form {
Section("排版") {
ReadingLabeledContent("字号", value: String(format: "%.0f", fontSize))
Slider(value: $fontSize, in: 5...50, step: 1)
ReadingLabeledContent("字间距", value: String(format: "%.2f", letterSpacing))
Slider(value: $letterSpacing, in: -0.5...0.5, step: 0.01)
ReadingLabeledContent("行间距", value: String(format: "%.0f", lineSpacing))
Slider(value: $lineSpacing, in: 0...50, step: 1)
ReadingLabeledContent("段距", value: String(format: "%.1f", paragraphSpacing))
Slider(value: $paragraphSpacing, in: 0...50, step: 1)
Picker("字重", selection: $fontWeight) {
Text("正常").tag(0)
Text("粗体").tag(1)
Text("细体").tag(2)
}
TextField("段落缩进字符", text: $paragraphIndent)
Picker("下划线", selection: $underlineMode) {
Text("关闭").tag(0)
Text("实线").tag(1)
Text("虚线").tag(2)
}
}
Section("正文边距") {
paddingSlider("左边距", value: $paddingLeft)
paddingSlider("右边距", value: $paddingRight)
paddingSlider("上边距", value: $paddingTop)
paddingSlider("下边距", value: $paddingBottom)
}
Section("章节标题") {
Picker("标题模式", selection: $titleMode) {
Text("左对齐").tag(0)
Text("居中").tag(1)
Text("隐藏").tag(2)
}
ReadingLabeledContent("字号增量", value: String(format: "%+.0f", titleSize))
Slider(value: $titleSize, in: -10...30, step: 1)
ReadingLabeledContent("上间距", value: String(Int(titleTopSpacing)))
Slider(value: $titleTopSpacing, in: 0...100, step: 1)
ReadingLabeledContent("下间距", value: String(Int(titleBottomSpacing)))
Slider(value: $titleBottomSpacing, in: 0...100, step: 1)
}
Section("翻页") {
Picker("翻页模式", selection: $pageAnimation) {
Text("覆盖").tag(0)
Text("滑动").tag(1)
Text("仿真").tag(2)
Text("滚动").tag(3)
Text("无动画").tag(4)
}
}
Section("屏幕") {
Toggle("阅读时不自动锁屏", isOn: $keepScreenOn)
Toggle("阅读时隐藏小横条", isOn: $hideHomeIndicator)
}
Section("阅读背景") {
ForEach(ReaderStylePreset.legadoDefaults) { preset in
Button {
theme = preset.id
} label: {
HStack {
RoundedRectangle(cornerRadius: 5)
.fill(Color(uiColor: preset.background))
.frame(width: 38, height: 28)
.overlay {
RoundedRectangle(cornerRadius: 5).stroke(Color.secondary.opacity(0.5))
}
Text(preset.name).foregroundStyle(.primary)
Spacer()
if ReaderStylePreset.resolve(theme).id == preset.id {
Image(systemName: "checkmark").foregroundStyle(ReadingTheme.accent)
}
}
}
}
}
Section("预览") {
Text("雨落在旧城的瓦檐上,故事从一束灯光开始。")
.tracking(letterSpacing * fontSize)
.font(.system(size: fontSize, design: .serif))
.lineSpacing(lineSpacing)
.foregroundStyle(Color(uiColor: ReaderStylePreset.resolve(theme).foreground))
.padding(.vertical, 12)
.listRowBackground(Color(uiColor: ReaderStylePreset.resolve(theme).background))
}
}
.navigationTitle("阅读设置")
}
private func paddingSlider(_ title: String, value: Binding<Double>) -> some View {
VStack {
ReadingLabeledContent(title, value: String(Int(value.wrappedValue)))
Slider(value: value, in: 0...100, step: 1)
}
}
}