mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
private final class CacheManagementModel: ObservableObject {
|
||||
@Published private(set) var entries = 0
|
||||
@Published private(set) var bytes = 0
|
||||
@Published private(set) var isWorking = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let cache: ChapterContentCache
|
||||
|
||||
init(cache: ChapterContentCache = .live()) { self.cache = cache }
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
let statistics = try await cache.statistics()
|
||||
entries = statistics.entries
|
||||
bytes = statistics.bytes
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func clear() async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
try await cache.removeAll()
|
||||
entries = 0
|
||||
bytes = 0
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CacheManagementView: View {
|
||||
@StateObject private var model = CacheManagementModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("章节正文") {
|
||||
ReadingLabeledContent("已缓存章节", value: "\(model.entries)")
|
||||
ReadingLabeledContent("占用空间", value: ByteCountFormatter.string(fromByteCount: Int64(model.bytes), countStyle: .file))
|
||||
Text("阅读网络章节后自动落盘,预加载数量可配置。缓存上限可在“其他设置 → 网络与缓存设置”调整,超限时优先清理最久未读取的章节。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Section {
|
||||
Button("清空章节缓存", systemImage: "trash", role: .destructive) {
|
||||
Task { await model.clear() }
|
||||
}
|
||||
.disabled(model.entries == 0 || model.isWorking)
|
||||
}
|
||||
}
|
||||
.navigationTitle("缓存管理")
|
||||
.task { await model.load() }
|
||||
.overlay { if model.isWorking { ProgressView() } }
|
||||
.alert("缓存操作失败", isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)) {
|
||||
Button("好") { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "未知错误")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
private final class FileManagementModel: ObservableObject {
|
||||
@Published var files: [LocalFileEntry] = []
|
||||
@Published var isWorking = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var readyExport: BookFileExport?
|
||||
@Published var partialExport: BookFileExport?
|
||||
private var activeExport: BookFileExport?
|
||||
private let exporter = BookFileExporter()
|
||||
|
||||
func reload(_ library: BookLibraryModel) async {
|
||||
do { files = try await library.localFiles.list() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func run(_ library: BookLibraryModel, operation: () async throws -> Void) async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do { try await operation() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
await reload(library)
|
||||
}
|
||||
|
||||
func importFiles(_ urls: [URL], library: BookLibraryModel) async {
|
||||
await run(library) {
|
||||
await library.importLocalFiles(urls)
|
||||
errorMessage = library.errorMessage
|
||||
library.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
func exportLocal(_ url: URL, filename: String, library: BookLibraryModel) async {
|
||||
await run(library) {
|
||||
let file = try await exporter.originalFile(url, filename: filename)
|
||||
activeExport = file
|
||||
readyExport = file
|
||||
}
|
||||
}
|
||||
|
||||
func exportCached(_ book: Book, library: BookLibraryModel) async {
|
||||
await run(library) {
|
||||
let chapters = try await AppBookChapterRepository.live().load(bookURL: book.bookUrl)
|
||||
let file = try await exporter.cachedBook(book, chapters: chapters)
|
||||
activeExport = file
|
||||
if file.isPartial { partialExport = file }
|
||||
else { readyExport = file }
|
||||
}
|
||||
}
|
||||
|
||||
func finishExport() {
|
||||
guard let file = activeExport else { return }
|
||||
activeExport = nil
|
||||
readyExport = nil
|
||||
partialExport = nil
|
||||
Task { try? await exporter.discard(file) }
|
||||
}
|
||||
}
|
||||
|
||||
struct FileManagementView: View {
|
||||
@EnvironmentObject private var library: BookLibraryModel
|
||||
@StateObject private var model = FileManagementModel()
|
||||
@State private var tab = 0
|
||||
@State private var query = ""
|
||||
@State private var sortBySize = false
|
||||
@State private var importsFile = false
|
||||
@State private var confirmsPartialExport = false
|
||||
@State private var selecting = false
|
||||
@State private var selection = Set<URL>()
|
||||
@State private var renameURL: URL?
|
||||
@State private var newName = ""
|
||||
@State private var pendingDeletion: [URL] = []
|
||||
|
||||
private var localBooks: [Book] { library.books.filter { $0.origin == "local" } }
|
||||
private var networkBooks: [Book] {
|
||||
library.books.filter { $0.origin != "local" && (query.isEmpty || $0.name.localizedCaseInsensitiveContains(query)) }
|
||||
}
|
||||
private func book(for url: URL) -> Book? { localBooks.first { $0.bookUrl == url.absoluteString } }
|
||||
private func filename(for url: URL) -> String {
|
||||
guard let book = book(for: url), !book.originName.isEmpty else { return url.lastPathComponent }
|
||||
return book.originName
|
||||
}
|
||||
private var displayedFiles: [LocalFileEntry] {
|
||||
model.files.filter { query.isEmpty || filename(for: $0.url).localizedCaseInsensitiveContains(query) }
|
||||
.sorted {
|
||||
if sortBySize, $0.bytes != $1.bytes { return $0.bytes > $1.bytes }
|
||||
return filename(for: $0.url).localizedStandardCompare(filename(for: $1.url)) == .orderedAscending
|
||||
}
|
||||
}
|
||||
private var missingBooks: [Book] {
|
||||
let available = Set(model.files.map(\.url.absoluteString))
|
||||
return localBooks.filter { !available.contains($0.bookUrl) && (query.isEmpty || $0.name.localizedCaseInsensitiveContains(query)) }
|
||||
}
|
||||
|
||||
private var fileList: some View {
|
||||
List(selection: $selection) {
|
||||
Section {
|
||||
Picker("文件类型", selection: $tab) {
|
||||
Text("本地文件").tag(0)
|
||||
Text("缓存正文").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(selecting)
|
||||
}
|
||||
if tab == 0 { localFilesSection }
|
||||
else { cachedBooksSection }
|
||||
if !selecting {
|
||||
Section {
|
||||
NavigationLink { CacheManagementView() } label: {
|
||||
Label("章节缓存与空间清理", systemImage: "internaldrive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.environment(\.editMode, .constant(selecting ? .active : .inactive))
|
||||
.navigationTitle("文件管理")
|
||||
.searchable(text: $query, prompt: tab == 0 ? "搜索本地文件" : "搜索缓存书籍")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if selecting {
|
||||
Button("完成") { selecting = false; selection.removeAll() }
|
||||
} else {
|
||||
Menu {
|
||||
Button("导入 TXT 文件", systemImage: "square.and.arrow.down") { importsFile = true }
|
||||
if tab == 0 {
|
||||
Button("选择文件", systemImage: "checkmark.circle") { selecting = true }
|
||||
.disabled(displayedFiles.isEmpty)
|
||||
Picker("排序", selection: $sortBySize) {
|
||||
Text("按名称").tag(false)
|
||||
Text("按大小").tag(true)
|
||||
}
|
||||
}
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if selecting {
|
||||
HStack {
|
||||
Button(selection.count == displayedFiles.count ? "取消全选" : "全选") {
|
||||
selection = selection.count == displayedFiles.count ? [] : Set(displayedFiles.map(\.url))
|
||||
}
|
||||
Spacer()
|
||||
Button("删除 \(selection.count) 个文件", role: .destructive) { pendingDeletion = Array(selection) }
|
||||
.disabled(selection.isEmpty)
|
||||
}
|
||||
.padding().background(.bar)
|
||||
}
|
||||
}
|
||||
.disabled(model.isWorking)
|
||||
.overlay { if model.isWorking { ProgressView("正在处理文件…").padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }
|
||||
.task { await model.reload(library) }
|
||||
.onChange(of: library.books) { _ in Task { await model.reload(library) } }
|
||||
.onChange(of: query) { _ in selection.removeAll() }
|
||||
.refreshable { await model.reload(library) }
|
||||
}
|
||||
|
||||
private var importAndRenameView: some View {
|
||||
fileList
|
||||
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.text, .plainText, .data], allowsMultipleSelection: true) { result in
|
||||
switch result {
|
||||
case .success(let urls): Task { await model.importFiles(urls, library: library) }
|
||||
case .failure(let error): model.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.alert("重命名文件", isPresented: Binding(get: { renameURL != nil }, set: { if !$0 { renameURL = nil } }), presenting: renameURL) { url in
|
||||
TextField("文件名", text: $newName)
|
||||
Button("取消", role: .cancel) { renameURL = nil }
|
||||
Button("保存") {
|
||||
let name = newName
|
||||
renameURL = nil
|
||||
Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } }
|
||||
}
|
||||
} message: { _ in
|
||||
Text("保留 TXT 格式,书架中的书名、书签和阅读进度不变。")
|
||||
}
|
||||
}
|
||||
|
||||
private var deletionView: some View {
|
||||
importAndRenameView
|
||||
.confirmationDialog("删除 \(pendingDeletion.count) 个文件?", isPresented: Binding(get: { !pendingDeletion.isEmpty }, set: { if !$0 { pendingDeletion = [] } }), titleVisibility: .visible, presenting: pendingDeletion) { urls in
|
||||
Button("删除文件并移出书架", role: .destructive) {
|
||||
pendingDeletion = []
|
||||
Task {
|
||||
await model.run(library) {
|
||||
var failures: [String] = []
|
||||
for url in urls {
|
||||
do { try await library.deleteLocalFile(at: url) }
|
||||
catch { failures.append("\(filename(for: url)):\(error.localizedDescription)") }
|
||||
}
|
||||
selection.removeAll()
|
||||
if !failures.isEmpty { model.errorMessage = failures.joined(separator: "\n") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) { pendingDeletion = [] }
|
||||
} message: { _ in
|
||||
Text("将删除应用内文件及对应书架条目,系统“文件”中用于导入的原文件保留。")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
deletionView
|
||||
.onChange(of: model.partialExport?.id) { value in confirmsPartialExport = value != nil }
|
||||
.onChange(of: confirmsPartialExport) { isPresented in
|
||||
if !isPresented {
|
||||
Task { @MainActor in
|
||||
await Task.yield()
|
||||
if !confirmsPartialExport, model.readyExport == nil { model.finishExport() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("缓存正文不完整", isPresented: $confirmsPartialExport, titleVisibility: .visible, presenting: model.partialExport) { file in
|
||||
Button("导出已缓存内容") {
|
||||
model.readyExport = file
|
||||
model.partialExport = nil
|
||||
}
|
||||
Button("取消", role: .cancel) { model.finishExport() }
|
||||
} message: { file in
|
||||
Text("已缓存 \(file.cachedChapters)/\(file.totalChapters) 章。导出文件会标记未缓存章节,可先返回详情缓存完整后再导出。")
|
||||
}
|
||||
.sheet(item: $model.readyExport, onDismiss: { model.finishExport() }) { file in
|
||||
FileExportPicker(url: file.url) { model.readyExport = nil }
|
||||
}
|
||||
.alert("文件操作失败", isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } })) {
|
||||
Button("好") { model.errorMessage = nil }
|
||||
} message: { Text(model.errorMessage ?? "未知错误") }
|
||||
}
|
||||
|
||||
@ViewBuilder private var localFilesSection: some View {
|
||||
Section {
|
||||
ReadingLabeledContent("本地文件", value: "\(model.files.count) 个")
|
||||
ReadingLabeledContent("占用空间", value: ByteCountFormatter.string(fromByteCount: model.files.reduce(0) { $0 + $1.bytes }, countStyle: .file))
|
||||
}
|
||||
Section("文件") {
|
||||
if displayedFiles.isEmpty {
|
||||
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT,文件会复制到应用中。"))
|
||||
Button("导入 TXT 文件", systemImage: "plus") { importsFile = true }
|
||||
}
|
||||
ForEach(displayedFiles) { file in
|
||||
Group {
|
||||
if selecting { fileLabel(file) }
|
||||
else if let book = book(for: file.url) {
|
||||
NavigationLink { BookshelfReadView(book: book, source: nil) } label: { fileLabel(file) }
|
||||
} else {
|
||||
Button { Task { await model.run(library) { try await library.addLocalFile(at: file.url) } } } label: { fileLabel(file) }
|
||||
}
|
||||
}
|
||||
.tag(file.url)
|
||||
.contextMenu {
|
||||
Button("导出文件", systemImage: "square.and.arrow.up") { export(file) }
|
||||
Button("重命名", systemImage: "pencil") { renameURL = file.url; newName = filename(for: file.url) }
|
||||
Button("删除文件", systemImage: "trash", role: .destructive) { pendingDeletion = [file.url] }
|
||||
}
|
||||
.swipeActions {
|
||||
Button("删除", role: .destructive) { pendingDeletion = [file.url] }
|
||||
Button("导出", systemImage: "square.and.arrow.up") { export(file) }.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !missingBooks.isEmpty {
|
||||
Section("文件已丢失") {
|
||||
ForEach(missingBooks) { book in
|
||||
HStack {
|
||||
Label(book.name, systemImage: "doc.badge.ellipsis")
|
||||
Spacer()
|
||||
Button("移出书架", role: .destructive) {
|
||||
if let url = URL(string: book.bookUrl) { pendingDeletion = [url] }
|
||||
}.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedBooksSection: some View {
|
||||
Section {
|
||||
Text("将书架中已缓存的网络正文导出为 TXT,按目录顺序排列。导出过程不下载新章节。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
if networkBooks.isEmpty {
|
||||
ReadingUnavailableView("没有可导出的书籍", systemImage: "books.vertical", description: Text("将网络书加入书架并缓存章节后,可在这里导出。"))
|
||||
}
|
||||
ForEach(networkBooks) { book in
|
||||
Button {
|
||||
Task { await model.exportCached(book, library: library) }
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(book.name).foregroundStyle(.primary)
|
||||
Text(book.author.isEmpty ? "导出缓存正文" : book.author).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: { Text("缓存正文") }
|
||||
}
|
||||
|
||||
private func fileLabel(_ file: LocalFileEntry) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "doc.text").foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(filename(for: file.url)).foregroundStyle(.primary).lineLimit(2)
|
||||
HStack(spacing: 8) {
|
||||
Text(ByteCountFormatter.string(fromByteCount: file.bytes, countStyle: .file))
|
||||
if let modified = file.modified { Text(modified, style: .date) }
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
if let book = book(for: file.url) {
|
||||
Text("书架:\(book.name)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
} else { Text("点按加入书架").font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func export(_ file: LocalFileEntry) {
|
||||
Task { await model.exportLocal(file.url, filename: filename(for: file.url), library: library) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct FileExportPicker: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
let onCompletion: () -> Void
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onCompletion) }
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
|
||||
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
let completion: () -> Void
|
||||
init(_ completion: @escaping () -> Void) { self.completion = completion }
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completion() }
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ReadingActivityModel: ObservableObject {
|
||||
@Published private(set) var bookmarks: [Bookmark] = []
|
||||
@Published private(set) var readRecords: [ReadRecord] = []
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let repository: any ReadingActivityRepository
|
||||
private let service = ReadingActivityService()
|
||||
private let deviceID: String
|
||||
private var sessions: [String: Date] = [:]
|
||||
|
||||
init(repository: any ReadingActivityRepository, deviceID: String) {
|
||||
self.repository = repository
|
||||
self.deviceID = deviceID
|
||||
}
|
||||
|
||||
static func live() -> ReadingActivityModel {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado", isDirectory: true)
|
||||
let key = "legado.reading.device-id"
|
||||
let defaults = UserDefaults.standard
|
||||
let identifier = defaults.string(forKey: key) ?? UUID().uuidString
|
||||
defaults.set(identifier, forKey: key)
|
||||
let bookmarksURL = base.appendingPathComponent("bookmarks.json")
|
||||
let recordsURL = base.appendingPathComponent("read-records.json")
|
||||
let repository: any ReadingActivityRepository
|
||||
if let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) {
|
||||
repository = SQLiteReadingActivityRepository(
|
||||
database: database,
|
||||
legacyBookmarksURL: bookmarksURL,
|
||||
legacyRecordsURL: recordsURL
|
||||
)
|
||||
} else {
|
||||
repository = FileReadingActivityRepository(bookmarksURL: bookmarksURL, recordsURL: recordsURL)
|
||||
}
|
||||
return ReadingActivityModel(repository: repository, deviceID: identifier)
|
||||
}
|
||||
|
||||
static func demo(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) -> ReadingActivityModel {
|
||||
ReadingActivityModel(
|
||||
repository: InMemoryReadingActivityRepository(bookmarks: bookmarks, records: records),
|
||||
deviceID: "ios-simulator"
|
||||
)
|
||||
}
|
||||
|
||||
var summaries: [ReadRecordSummary] { service.summaries(readRecords) }
|
||||
var statistics: ReadingStatistics { service.statistics(readRecords) }
|
||||
|
||||
func summaries(searchKey: String, sortMode: Int) -> [ReadRecordSummary] {
|
||||
service.displayedSummaries(readRecords, searchKey: searchKey, sortMode: sortMode)
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
async let storedBookmarks = repository.loadBookmarks()
|
||||
async let storedRecords = repository.loadReadRecords()
|
||||
bookmarks = service.sortedBookmarks(try await storedBookmarks)
|
||||
readRecords = try await storedRecords
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func bookmarks(for book: Book) -> [Bookmark] {
|
||||
bookmarks.filter { $0.bookName == book.name && $0.bookAuthor == book.author }
|
||||
.sorted {
|
||||
if $0.chapterIndex == $1.chapterIndex { return $0.chapterPosition < $1.chapterPosition }
|
||||
return $0.chapterIndex < $1.chapterIndex
|
||||
}
|
||||
}
|
||||
|
||||
func addBookmark(book: Book, chapter: BookChapter, position: Int, content: String) async {
|
||||
var timestamp = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
while bookmarks.contains(where: { $0.time == timestamp }) { timestamp += 1 }
|
||||
var bookmark = Bookmark(time: timestamp, bookName: book.name, bookAuthor: book.author)
|
||||
bookmark.chapterIndex = chapter.index
|
||||
bookmark.chapterPosition = max(position, 0)
|
||||
bookmark.chapterName = chapter.title
|
||||
bookmark.bookText = excerpt(content, around: position)
|
||||
var updated = bookmarks
|
||||
updated.append(bookmark)
|
||||
do {
|
||||
try await repository.saveBookmarks(updated)
|
||||
bookmarks = service.sortedBookmarks(updated)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func removeBookmark(_ bookmark: Bookmark) async {
|
||||
let updated = bookmarks.filter { $0.id != bookmark.id }
|
||||
do {
|
||||
try await repository.saveBookmarks(updated)
|
||||
bookmarks = updated
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updateBookmark(_ bookmark: Bookmark) async {
|
||||
var updated = bookmarks
|
||||
if let index = updated.firstIndex(where: { $0.id == bookmark.id }) {
|
||||
updated[index] = bookmark
|
||||
} else {
|
||||
updated.append(bookmark)
|
||||
}
|
||||
do {
|
||||
try await repository.saveBookmarks(updated)
|
||||
bookmarks = service.sortedBookmarks(updated)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func beginReading(_ book: Book, now: Date = Date()) {
|
||||
sessions[book.bookUrl] = sessions[book.bookUrl] ?? now
|
||||
}
|
||||
|
||||
func checkpoint(_ book: Book, now: Date = Date()) async {
|
||||
guard let start = sessions[book.bookUrl] else {
|
||||
sessions[book.bookUrl] = now
|
||||
return
|
||||
}
|
||||
sessions[book.bookUrl] = now
|
||||
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
|
||||
}
|
||||
|
||||
func endReading(_ book: Book, now: Date = Date()) async {
|
||||
guard let start = sessions.removeValue(forKey: book.bookUrl) else { return }
|
||||
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
|
||||
}
|
||||
|
||||
func cancelReading(_ book: Book) {
|
||||
sessions.removeValue(forKey: book.bookUrl)
|
||||
}
|
||||
|
||||
func removeHistory(bookName: String) async {
|
||||
let updated = readRecords.filter { $0.bookName != bookName }
|
||||
do {
|
||||
try await repository.saveReadRecords(updated)
|
||||
readRecords = updated
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func clearHistory() async {
|
||||
do {
|
||||
try await repository.saveReadRecords([])
|
||||
readRecords = []
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func clearError() { errorMessage = nil }
|
||||
|
||||
private func record(book: Book, elapsed: TimeInterval, now: Date) async {
|
||||
let milliseconds = Int64(max(elapsed, 0) * 1_000)
|
||||
guard milliseconds > 0 else { return }
|
||||
let updated = service.addingReadTime(
|
||||
milliseconds,
|
||||
deviceID: deviceID,
|
||||
bookName: book.name,
|
||||
now: Int64(now.timeIntervalSince1970 * 1_000),
|
||||
to: readRecords
|
||||
)
|
||||
readRecords = updated
|
||||
do {
|
||||
try await repository.saveReadRecords(updated)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func excerpt(_ content: String, around position: Int) -> String {
|
||||
guard !content.isEmpty else { return "" }
|
||||
let center = min(max(position, 0), content.count)
|
||||
let start = max(center - 24, 0)
|
||||
let end = min(center + 96, content.count)
|
||||
let lower = content.index(content.startIndex, offsetBy: start)
|
||||
let upper = content.index(content.startIndex, offsetBy: end)
|
||||
return String(content[lower..<upper]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ReadingHistoryView: View {
|
||||
@EnvironmentObject private var activity: ReadingActivityModel
|
||||
@EnvironmentObject private var books: BookLibraryModel
|
||||
@EnvironmentObject private var sources: SourceLibraryModel
|
||||
@AppStorage("enableReadRecord") private var enableReadRecord = true
|
||||
@AppStorage("readRecordSort") private var sortMode = 0
|
||||
@State private var searchText = ""
|
||||
@State private var confirmsClear = false
|
||||
@State private var pendingDelete: ReadRecordSummary?
|
||||
|
||||
private var displayedRecords: [ReadRecordSummary] {
|
||||
activity.summaries(searchKey: searchText, sortMode: sortMode)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if activity.summaries.isEmpty {
|
||||
ReadingUnavailableView(
|
||||
"还没有阅读记录",
|
||||
systemImage: "clock.arrow.circlepath",
|
||||
description: Text(enableReadRecord ? "翻页后会按设备累计阅读时长。" : "阅读记录当前已关闭。")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
ReadingLabeledContent("累计阅读", value: duration(activity.summaries.reduce(0) { $0 + $1.readTime }))
|
||||
ReadingLabeledContent("读过", value: "\(activity.summaries.count) 本")
|
||||
}
|
||||
if displayedRecords.isEmpty {
|
||||
ReadingUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
ForEach(displayedRecords) { record in
|
||||
NavigationLink {
|
||||
destination(for: record)
|
||||
} label: {
|
||||
HStack(spacing: 13) {
|
||||
Image(systemName: "book.pages.fill")
|
||||
.foregroundStyle(ReadingTheme.accent)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 11))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(record.bookName).font(.headline)
|
||||
Text(duration(record.readTime))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(date(record.lastRead)).font(.caption).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
Button("删除", role: .destructive) { pendingDelete = record }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
.navigationTitle("阅读记录")
|
||||
.searchable(text: $searchText, prompt: "搜索")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu("阅读记录选项", systemImage: "ellipsis.circle") {
|
||||
Toggle("记录阅读时长", isOn: $enableReadRecord)
|
||||
Section("排序") {
|
||||
sortButton("书名", value: 0)
|
||||
sortButton("阅读时长", value: 1)
|
||||
sortButton("最后阅读", value: 2)
|
||||
}
|
||||
if !activity.summaries.isEmpty {
|
||||
Button("清空", systemImage: "trash", role: .destructive) { confirmsClear = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("清空阅读记录", isPresented: $confirmsClear) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("删除", role: .destructive) { Task { await activity.clearHistory() } }
|
||||
} message: {
|
||||
Text("确定删除全部阅读记录吗?")
|
||||
}
|
||||
.alert("删除阅读记录", isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
)) {
|
||||
Button("取消", role: .cancel) { pendingDelete = nil }
|
||||
Button("删除", role: .destructive) {
|
||||
guard let record = pendingDelete else { return }
|
||||
pendingDelete = nil
|
||||
Task { await activity.removeHistory(bookName: record.bookName) }
|
||||
}
|
||||
} message: {
|
||||
Text("确定删除“\(pendingDelete?.bookName ?? "")”的阅读记录吗?")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func destination(for record: ReadRecordSummary) -> some View {
|
||||
if let book = books.books.first(where: { $0.name == record.bookName }) {
|
||||
BookshelfReadView(
|
||||
book: book,
|
||||
source: sources.sources.first(where: { $0.bookSourceUrl == book.origin })
|
||||
)
|
||||
} else {
|
||||
SearchView(initialKeyword: record.bookName)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func sortButton(_ title: String, value: Int) -> some View {
|
||||
Button {
|
||||
sortMode = value
|
||||
} label: {
|
||||
if sortMode == value { Label(title, systemImage: "checkmark") }
|
||||
else { Text(title) }
|
||||
}
|
||||
}
|
||||
|
||||
private func duration(_ milliseconds: Int64) -> String {
|
||||
let value = max(milliseconds, 0)
|
||||
let days = value / 86_400_000
|
||||
let hours = value % 86_400_000 / 3_600_000
|
||||
let minutes = value % 3_600_000 / 60_000
|
||||
let seconds = value % 60_000 / 1_000
|
||||
var result = ""
|
||||
if days > 0 { result += "\(days)天" }
|
||||
if hours > 0 { result += "\(hours)小时" }
|
||||
if minutes > 0 { result += "\(minutes)分钟" }
|
||||
if seconds > 0 { result += "\(seconds)秒" }
|
||||
return result.isEmpty ? "0秒" : result
|
||||
}
|
||||
|
||||
private func date(_ milliseconds: Int64) -> String {
|
||||
let formatter = DateFormatter()
|
||||
guard milliseconds > 0 else { return "" }
|
||||
formatter.locale = .current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: Date(timeIntervalSince1970: Double(milliseconds) / 1_000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ReadingStatisticsView: View {
|
||||
@EnvironmentObject private var activity: ReadingActivityModel
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var durationFontSize = 42
|
||||
@ScaledMetric(relativeTo: .caption2) private var chartHeight = 138
|
||||
|
||||
private var statistics: ReadingStatistics { activity.statistics }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ReadingPageHeader(title: "阅读统计", subtitle: "每一页,都算数") { EmptyView() }
|
||||
VStack(spacing: 16) {
|
||||
todayCard
|
||||
overviewGrid
|
||||
weeklyCard
|
||||
historyLink
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
|
||||
private var todayCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("今日阅读")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(Date.now.formatted(.dateTime.locale(Locale(identifier: "zh_CN")).month().day()))
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(ReadingTheme.onAccent.opacity(0.7))
|
||||
Text(duration(statistics.todayReadTime, compact: false))
|
||||
.font(.system(size: durationFontSize, weight: .medium))
|
||||
.foregroundStyle(ReadingTheme.onAccent)
|
||||
.readingNumericTransition()
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
Text(statistics.todayReadTime == 0 ? "今天还没有阅读记录" : "已记录今天的阅读时长")
|
||||
.font(.caption)
|
||||
.foregroundStyle(ReadingTheme.onAccent.opacity(0.65))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(24)
|
||||
.background(ReadingTheme.accent, in: RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
|
||||
private var overviewGrid: some View {
|
||||
ReadingCard {
|
||||
VStack(spacing: 18) {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
statisticTile(title: "累计阅读", value: duration(statistics.totalReadTime, compact: true))
|
||||
statisticTile(title: "阅读书籍", value: "\(statistics.bookCount) 本")
|
||||
}
|
||||
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
statisticTile(
|
||||
title: "近 7 天",
|
||||
value: duration(statistics.recentDays.reduce(0) { $0 + $1.readTime }, compact: true)
|
||||
)
|
||||
statisticTile(title: "连续阅读", value: "\(statistics.currentStreak) 天")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statisticTile(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.title3.weight(.semibold))
|
||||
.readingNumericTransition()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(title),\(value)")
|
||||
}
|
||||
|
||||
private var weeklyCard: some View {
|
||||
ReadingCard {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack {
|
||||
Text("最近 7 天")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("每日阅读时长")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
ForEach(statistics.recentDays, id: \.date) { day in
|
||||
VStack(spacing: 7) {
|
||||
Text(barLabel(day.readTime))
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(day.readTime > 0 ? ReadingTheme.accent.opacity(Calendar.current.isDateInToday(day.date) ? 1 : 0.22) : ReadingTheme.separator)
|
||||
.frame(width: 22, height: barHeight(day.readTime))
|
||||
Text(weekday(day.date))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Calendar.current.isDateInToday(day.date) ? ReadingTheme.accent : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(weekday(day.date)),阅读 \(duration(day.readTime, compact: false))")
|
||||
}
|
||||
}
|
||||
.frame(height: chartHeight, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var historyLink: some View {
|
||||
NavigationLink {
|
||||
ReadingHistoryView()
|
||||
} label: {
|
||||
ReadingCard {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "clock.arrow.circlepath").font(.title3)
|
||||
Text("阅读记录").font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right").font(.subheadline)
|
||||
}
|
||||
.foregroundStyle(ReadingTheme.accent)
|
||||
.frame(minHeight: 24)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func barHeight(_ milliseconds: Int64) -> CGFloat {
|
||||
let maximum = max(statistics.recentDays.map(\.readTime).max() ?? 0, 1)
|
||||
guard milliseconds > 0 else { return 2 }
|
||||
return 12 + 70 * CGFloat(milliseconds) / CGFloat(maximum)
|
||||
}
|
||||
|
||||
private func barLabel(_ milliseconds: Int64) -> String {
|
||||
guard milliseconds >= 60_000 else { return milliseconds > 0 ? "<1分" : "0" }
|
||||
if milliseconds < 3_600_000 { return "\(milliseconds / 60_000)分" }
|
||||
let hours = Double(milliseconds) / 3_600_000
|
||||
return String(format: "%.1f时", hours)
|
||||
}
|
||||
|
||||
private func weekday(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "EEE"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func duration(_ milliseconds: Int64, compact: Bool) -> String {
|
||||
let value = max(milliseconds, 0)
|
||||
let hours = value / 3_600_000
|
||||
let minutes = value % 3_600_000 / 60_000
|
||||
if compact {
|
||||
if hours > 0 { return minutes > 0 ? "\(hours)时\(minutes)分" : "\(hours)小时" }
|
||||
return "\(minutes)分钟"
|
||||
}
|
||||
let seconds = value % 60_000 / 1_000
|
||||
if hours > 0 { return minutes > 0 ? "\(hours)小时 \(minutes)分钟" : "\(hours)小时" }
|
||||
if minutes > 0 { return "\(minutes)分钟" }
|
||||
return "\(seconds)秒"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user