mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 21:01:22 +08:00
fix: 修复书源导入、阅读外观和书签状态
- 修复书源文件选择、访问权限与导入预览流程,支持查看分组内的书源 - 修复背景图片、字体选择与主题切换,限制背景图片解码尺寸 - 根据已保存书签恢复当前位置状态,避免重复添加 - 补充模型、阅读器和文件选择回归测试及测试样本 - 更新应用版本为 0.1.10(build 16) Fixes #1
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import CoreText
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@@ -54,25 +55,43 @@ private extension UIColor {
|
||||
}
|
||||
|
||||
enum ReaderBackgroundStore {
|
||||
static let maximumPixelSize = 2560
|
||||
|
||||
static var url: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/reader-background.jpg")
|
||||
}
|
||||
|
||||
static func load() -> UIImage? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return UIImage(data: data)
|
||||
static func load(from source: URL = url) -> UIImage? {
|
||||
guard let data = try? Data(contentsOf: source, options: .mappedIfSafe) else { return nil }
|
||||
return try? downsample(data)
|
||||
}
|
||||
|
||||
static func save(_ data: Data) throws -> UIImage {
|
||||
guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else {
|
||||
static func save(_ data: Data, to target: URL = url) throws -> UIImage {
|
||||
let image = try downsample(data)
|
||||
guard let encoded = image.jpegData(compressionQuality: 0.9) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try encoded.write(to: url, options: .atomic)
|
||||
try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try encoded.write(to: target, options: .atomic)
|
||||
return image
|
||||
}
|
||||
|
||||
private static func downsample(_ data: Data) throws -> UIImage {
|
||||
// Decode only the size used by the reader, including backgrounds saved
|
||||
// by older versions. Full-resolution camera photos can exhaust memory.
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize
|
||||
] as CFDictionary) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
return UIImage(cgImage: image)
|
||||
}
|
||||
|
||||
static func remove() { try? FileManager.default.removeItem(at: url) }
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ struct ReaderView: View {
|
||||
@State private var contentEditError: String?
|
||||
@State private var backgroundImage: UIImage?
|
||||
@State private var backgroundError: String?
|
||||
@State private var showsBackgroundPicker = false
|
||||
@State private var showsFontImporter = false
|
||||
@State private var fontError: String?
|
||||
@State private var readerJumpPosition: Int?
|
||||
@@ -55,7 +56,7 @@ struct ReaderView: View {
|
||||
@State private var progressSaveGeneration = 0
|
||||
@State private var speechStartGeneration = 0
|
||||
@State private var speechTimerRemaining = 0
|
||||
@State private var bookmarkSaved = false
|
||||
@State private var isSavingBookmark = false
|
||||
@ScaledMetric(relativeTo: .body) private var toolbarIconSize = 21
|
||||
@AppStorage("textSize") private var fontSize = 20.0
|
||||
@AppStorage("letterSpacing") private var letterSpacing = 0.1
|
||||
@@ -282,19 +283,6 @@ struct ReaderView: View {
|
||||
.sheet(isPresented: $showsReaderSettings) { readerSettings }
|
||||
.sheet(isPresented: $showsClickActionSettings) { clickActionSettings }
|
||||
.sheet(isPresented: $showsContentEditor) { contentEditor }
|
||||
.fileImporter(
|
||||
isPresented: $showsFontImporter,
|
||||
allowedContentTypes: [.font, .data],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
fontError = nil
|
||||
do {
|
||||
guard let source = try result.get().first else { return }
|
||||
fontPath = try ReaderFontStore.install(from: source)
|
||||
} catch {
|
||||
fontError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.task(id: model.chapterLoadGeneration) {
|
||||
isWaitingForNextChapter = false
|
||||
isWaitingForPreviousChapter = false
|
||||
@@ -413,7 +401,7 @@ struct ReaderView: View {
|
||||
.background {
|
||||
ZStack {
|
||||
backgroundColor
|
||||
if themeMode != "3", useBackgroundImage, let backgroundImage {
|
||||
if let backgroundImage = activeBackgroundImage {
|
||||
Image(uiImage: backgroundImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
@@ -577,7 +565,7 @@ struct ReaderView: View {
|
||||
pageAnimation: pageAnimation,
|
||||
isEInkMode: themeMode == "3",
|
||||
clickActions: clickActions,
|
||||
backgroundColor: themeMode != "3" && useBackgroundImage && backgroundImage != nil ? .clear : resolvedStyle.background,
|
||||
backgroundColor: activeBackgroundImage == nil ? resolvedStyle.background : .clear,
|
||||
foregroundColor: resolvedStyle.foreground,
|
||||
requestedPosition: pageAnimation == 3 ? readerPosition : model.requestedPosition,
|
||||
jumpPosition: readerJumpPosition,
|
||||
@@ -678,6 +666,10 @@ struct ReaderView: View {
|
||||
return min(max(position, 0), max(length - 1, 0))
|
||||
}
|
||||
|
||||
private var activeBackgroundImage: UIImage? {
|
||||
themeMode != "3" && useBackgroundImage ? backgroundImage : nil
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
Color(uiColor: resolvedStyle.background)
|
||||
}
|
||||
@@ -822,6 +814,7 @@ struct ReaderView: View {
|
||||
addBookmark()
|
||||
}
|
||||
.accessibilityValue(bookmarkSaved ? "已添加" : "添加当前位置")
|
||||
.disabled(isSavingBookmark)
|
||||
readerMoreMenu
|
||||
}
|
||||
.foregroundStyle(readerChromeTint)
|
||||
@@ -1160,11 +1153,13 @@ struct ReaderView: View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(ReaderStylePreset.legadoDefaults) { preset in
|
||||
let isSelected = resolvedStyle.id == preset.id && activeBackgroundImage == nil
|
||||
Button {
|
||||
useBackgroundImage = false
|
||||
theme = preset.id
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(uiColor: preset.background))
|
||||
.frame(width: 54, height: 72)
|
||||
.overlay {
|
||||
@@ -1173,31 +1168,46 @@ struct ReaderView: View {
|
||||
.foregroundStyle(Color(uiColor: preset.foreground))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.stroke(resolvedStyle.id == preset.id ? ReadingTheme.accent : Color.secondary, lineWidth: resolvedStyle.id == preset.id ? 3 : 1)
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color(uiColor: preset.foreground).opacity(isSelected ? 0.4 : 0.12),
|
||||
lineWidth: isSelected ? 1.25 : 0.75
|
||||
)
|
||||
}
|
||||
Text(preset.name).font(.caption2).foregroundStyle(.primary)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(Color(uiColor: preset.foreground))
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
Text(preset.name)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(isSelected ? .primary : .secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(preset.name)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
ReadingPhotoPicker { data in
|
||||
Button("选择背景图片", systemImage: "photo") {
|
||||
backgroundError = nil
|
||||
do {
|
||||
backgroundImage = try ReaderBackgroundStore.save(data)
|
||||
useBackgroundImage = true
|
||||
} catch {
|
||||
backgroundError = error.localizedDescription
|
||||
}
|
||||
} onError: { error in
|
||||
backgroundError = error.localizedDescription
|
||||
} label: {
|
||||
Label("选择背景图片", systemImage: "photo")
|
||||
showsBackgroundPicker = true
|
||||
}
|
||||
if backgroundImage != nil {
|
||||
Toggle("使用背景图片", isOn: $useBackgroundImage)
|
||||
HStack(spacing: 0) {
|
||||
Text("使用背景图片")
|
||||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { useBackgroundImage.toggle() }
|
||||
.accessibilityHidden(true)
|
||||
Toggle("使用背景图片", isOn: $useBackgroundImage)
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
Button("删除背景图片", systemImage: "trash", role: .destructive) {
|
||||
ReaderBackgroundStore.remove()
|
||||
backgroundImage = nil
|
||||
@@ -1219,8 +1229,15 @@ struct ReaderView: View {
|
||||
Text("粗体").tag(1)
|
||||
Text("细体").tag(2)
|
||||
}
|
||||
TextField("段落缩进字符", text: $paragraphIndent)
|
||||
.textInputAutocapitalization(.never)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("段落缩进")
|
||||
TextField("请输入缩进字符", text: $paragraphIndent)
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("段落缩进字符")
|
||||
Text("控制每段开头的缩进,默认两个全角空格;清空后不缩进。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Button(fontPath.isEmpty ? "选择字体文件" : "更换字体文件", systemImage: "textformat") {
|
||||
showsFontImporter = true
|
||||
}
|
||||
@@ -1335,6 +1352,30 @@ struct ReaderView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.readingPhotoPicker(isPresented: $showsBackgroundPicker) { data in
|
||||
backgroundError = nil
|
||||
do {
|
||||
backgroundImage = try ReaderBackgroundStore.save(data)
|
||||
useBackgroundImage = true
|
||||
} catch {
|
||||
backgroundError = error.localizedDescription
|
||||
}
|
||||
} onError: { error in
|
||||
backgroundError = error.localizedDescription
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showsFontImporter,
|
||||
allowedContentTypes: [.font, .data],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
fontError = nil
|
||||
do {
|
||||
guard let source = try result.get().first else { return }
|
||||
fontPath = try ReaderFontStore.install(from: source)
|
||||
} catch {
|
||||
fontError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.readingPresentationDetents([.medium])
|
||||
.readingPresentationDragIndicator()
|
||||
}
|
||||
@@ -1866,18 +1907,26 @@ struct ReaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var bookmarkSaved: Bool {
|
||||
let page = currentPage - 1
|
||||
let range = pageAnimation != 3 && readerPageRanges.indices.contains(page) ? readerPageRanges[page] : nil
|
||||
return !model.bookmarks(at: readerPosition, in: activity.bookmarks, pageRange: range).isEmpty
|
||||
}
|
||||
|
||||
private func addBookmark() {
|
||||
guard let chapter = model.currentChapter else { return }
|
||||
bookmarkSaved = true
|
||||
guard !isSavingBookmark, !bookmarkSaved, let chapter = model.currentChapter else { return }
|
||||
let currentBook = book
|
||||
let position = readerPosition
|
||||
let content = model.content
|
||||
isSavingBookmark = true
|
||||
Task {
|
||||
defer { isSavingBookmark = false }
|
||||
await activity.addBookmark(
|
||||
book: book,
|
||||
book: currentBook,
|
||||
chapter: chapter,
|
||||
position: readerPosition,
|
||||
content: model.content
|
||||
position: position,
|
||||
content: content
|
||||
)
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
bookmarkSaved = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@ final class ReaderViewModel: ObservableObject {
|
||||
private var sourceCacheNeedsInvalidation = false
|
||||
private var pendingSourceContent: ChapterContentReport?
|
||||
|
||||
func bookmarks(at position: Int, in bookmarks: [Bookmark], pageRange: NSRange? = nil) -> [Bookmark] {
|
||||
bookmarks.filter { bookmark in
|
||||
guard bookmark.bookName == book.name, bookmark.bookAuthor == book.author,
|
||||
bookmark.resolvedChapterIndex(in: chapters) == currentIndex else { return false }
|
||||
if bookmark.chapterPosition == max(position, 0) { return true }
|
||||
if let pageRange, pageRange.length > 0 {
|
||||
return NSLocationInRange(bookmark.chapterPosition, pageRange)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func prepareContinuousReading(rules: [ReplaceRule]) async {
|
||||
guard hasLoadedCurrentChapter else { return }
|
||||
if scrollChapters.isEmpty {
|
||||
|
||||
@@ -4,6 +4,36 @@ import LegadoCore
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class SourceImportFile: @unchecked Sendable {
|
||||
let url: URL
|
||||
private let hasSecurityScope: Bool
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
hasSecurityScope = url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
deinit {
|
||||
if hasSecurityScope { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
|
||||
func read() throws -> Data {
|
||||
var coordinationError: NSError?
|
||||
var result: Result<Data, Error>?
|
||||
NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { readableURL in
|
||||
result = Result {
|
||||
guard try readableURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory != true else {
|
||||
throw BookSourceImportError.invalidDocument("请选择书源 JSON 文件,而不是文件夹。")
|
||||
}
|
||||
return try Data(contentsOf: readableURL)
|
||||
}
|
||||
}
|
||||
if let coordinationError { throw coordinationError }
|
||||
guard let result else { throw CocoaError(.fileReadUnknown) }
|
||||
return try result.get()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SourceLibraryModel: ObservableObject {
|
||||
@Published private(set) var sources: [BookSource] = []
|
||||
@@ -168,16 +198,18 @@ final class SourceLibraryModel: ObservableObject {
|
||||
}
|
||||
|
||||
func previewFiles(_ urls: [URL]) async {
|
||||
await previewPickedFiles(urls.map { SourceImportFile(url: $0) })
|
||||
}
|
||||
|
||||
func previewPickedFiles(_ files: [SourceImportFile]) async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
var incoming: [String: BookSource] = [:]
|
||||
var invalidItems: [PendingImport.InvalidItem] = []
|
||||
for url in urls {
|
||||
let granted = url.startAccessingSecurityScopedResource()
|
||||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||||
for file in files {
|
||||
let data = try await Task.detached(priority: .userInitiated) { [importer] in
|
||||
try importer.normalizedData(Data(contentsOf: url))
|
||||
try importer.normalizedData(file.read())
|
||||
}.value
|
||||
var budget = 100
|
||||
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
|
||||
@@ -186,10 +218,6 @@ final class SourceLibraryModel: ObservableObject {
|
||||
incoming[source.id] = source
|
||||
}
|
||||
}
|
||||
// UIDocumentPicker is still dismissing when its completion runs.
|
||||
// Presenting the review sheet in that same frame is ignored by
|
||||
// SwiftUI, so stage it after the picker has left the hierarchy.
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
stage(Array(incoming.values), invalidItems: invalidItems)
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||||
|
||||
@@ -5,6 +5,8 @@ import UniformTypeIdentifiers
|
||||
struct SourceManagementView: View {
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
@State private var showsImporter = false
|
||||
@State private var pickedImportFiles: [SourceImportFile] = []
|
||||
@State private var importPickerHasDismissed = true
|
||||
@State private var showsNewSource = false
|
||||
@State private var showsQRImporter = false
|
||||
@State private var showsTextImporter = false
|
||||
@@ -58,7 +60,7 @@ struct SourceManagementView: View {
|
||||
} description: {
|
||||
Text("新建书源,或从文件、链接、JSON、二维码导入。")
|
||||
} actions: {
|
||||
Button("导入书源") { showsImporter = true }
|
||||
Button("导入书源", action: presentFileImporter)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
@@ -142,7 +144,7 @@ struct SourceManagementView: View {
|
||||
Menu("更多", systemImage: "ellipsis.circle") {
|
||||
Button("新建书源", systemImage: "plus") { showsNewSource = true }
|
||||
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
|
||||
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
|
||||
Button("从文件导入", systemImage: "doc.badge.plus", action: presentFileImporter)
|
||||
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
|
||||
Divider()
|
||||
NavigationLink {
|
||||
@@ -206,24 +208,71 @@ struct SourceManagementView: View {
|
||||
.sheet(isPresented: $showsBatchGroups) {
|
||||
SourceBatchGroupView(sourceIDs: selectedSourceIDs).environmentObject(library)
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showsImporter,
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
let files = urls.filter { !$0.hasDirectoryPath }
|
||||
if files.isEmpty {
|
||||
library.notice = .init(kind: .error, title: "无法选择文件", detail: "请选择书源 JSON 文件,而不是文件夹。")
|
||||
} else {
|
||||
Task { await library.previewFiles(files) }
|
||||
}
|
||||
case .failure(let error):
|
||||
library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
|
||||
.sheet(isPresented: $showsImporter, onDismiss: {
|
||||
importPickerHasDismissed = true
|
||||
previewPickedFilesWhenReady()
|
||||
}) {
|
||||
SourceFileImportPicker { files in
|
||||
pickedImportFiles = files
|
||||
showsImporter = false
|
||||
previewPickedFilesWhenReady()
|
||||
} onCancel: {
|
||||
showsImporter = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentFileImporter() {
|
||||
pickedImportFiles = []
|
||||
importPickerHasDismissed = false
|
||||
showsImporter = true
|
||||
}
|
||||
|
||||
private func previewPickedFilesWhenReady() {
|
||||
// Import-copy mode can dismiss before returning URLs. Other providers
|
||||
// return URLs first, so wait for both events instead of assuming order.
|
||||
guard importPickerHasDismissed, !pickedImportFiles.isEmpty else { return }
|
||||
let files = pickedImportFiles
|
||||
pickedImportFiles = []
|
||||
Task { await library.previewPickedFiles(files) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceFileImportPicker: UIViewControllerRepresentable {
|
||||
let onPick: @MainActor ([SourceImportFile]) -> Void
|
||||
let onCancel: @MainActor () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
// Ask the provider to import copies, including JSON files whose provider
|
||||
// does not support opening documents in place. Multi-select gives Files
|
||||
// an explicit confirmation action for the selected files.
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true)
|
||||
picker.allowsMultipleSelection = true
|
||||
picker.shouldShowFileExtensions = true
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
private let parent: SourceFileImportPicker
|
||||
|
||||
init(parent: SourceFileImportPicker) { self.parent = parent }
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
parent.onCancel()
|
||||
}
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
// Hold the picker grants before dismissing, until the model has
|
||||
// finished its coordinated background reads.
|
||||
parent.onPick(urls.map { SourceImportFile(url: $0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceTextImportView: View {
|
||||
@@ -337,16 +386,22 @@ private struct SourceGroupManagerView: View {
|
||||
}
|
||||
.disabled(BookSource.parseGroups(newGroup).isEmpty)
|
||||
}
|
||||
Text("新分组会分配给当前未分组书源,与 Legado 的分组管理行为一致。")
|
||||
Text("添加后,所有未分组的书源都会加入该分组。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("已有分组") {
|
||||
Section {
|
||||
ForEach(library.groups, id: \.self) { group in
|
||||
HStack {
|
||||
Label(group, systemImage: "folder.fill")
|
||||
Spacer()
|
||||
Text("\(library.sources.filter { $0.hasGroup(group) }.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
NavigationLink {
|
||||
SourceGroupContentsView(group: group)
|
||||
} label: {
|
||||
HStack {
|
||||
Label(group, systemImage: "folder.fill")
|
||||
Spacer()
|
||||
Text("\(library.sources.filter { $0.hasGroup(group) }.count) 个书源")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
Button("重命名", systemImage: "pencil") {
|
||||
renaming = group
|
||||
@@ -356,8 +411,14 @@ private struct SourceGroupManagerView: View {
|
||||
Task { await library.deleteGroup(group) }
|
||||
}
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel("\(group)分组操作")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("已有分组")
|
||||
} footer: {
|
||||
Text("点击分组可查看其中的书源。")
|
||||
}
|
||||
}
|
||||
.navigationTitle("书源分组")
|
||||
@@ -378,6 +439,42 @@ private struct SourceGroupManagerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceGroupContentsView: View {
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let group: String
|
||||
|
||||
private var sources: [BookSource] {
|
||||
library.sources.filter { $0.hasGroup(group) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(sources) { source in
|
||||
NavigationLink {
|
||||
SourceDetailView(source: source)
|
||||
} label: {
|
||||
SourceRow(source: source) { enabled in
|
||||
Task { await library.setEnabled(enabled, for: source.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if sources.isEmpty {
|
||||
Text("该分组暂无书源")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("\(sources.count) 个书源")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.readingScrollContentBackgroundHidden()
|
||||
.background(ReadingTheme.groupedCanvas)
|
||||
.navigationTitle(group)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceBatchGroupView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
|
||||
Reference in New Issue
Block a user