阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
348 lines
14 KiB
Swift
348 lines
14 KiB
Swift
import UIKit
|
||
|
||
final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||
|
||
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
|
||
|
||
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
|
||
|
||
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
|
||
|
||
private var highlights: [RDEPUBHighlight]
|
||
|
||
private let theme: RDEPUBReaderTheme
|
||
|
||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||
|
||
private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"])
|
||
|
||
/// 空状态提示。放在 tableView 的子视图而不是 `backgroundView`:
|
||
/// backgroundView 不参与无障碍层级遍历,VoiceOver 与 UI 测试都读不到它。
|
||
private let emptyStateLabel = UILabel()
|
||
|
||
private var filteredHighlights: [RDEPUBHighlight] {
|
||
switch filterControl.selectedSegmentIndex {
|
||
case 1:
|
||
return highlights.filter(\.hasNote)
|
||
case 2:
|
||
return highlights.filter { !$0.hasNote }
|
||
default:
|
||
return highlights
|
||
}
|
||
}
|
||
|
||
init(
|
||
highlights: [RDEPUBHighlight],
|
||
theme: RDEPUBReaderTheme,
|
||
sectionTitleProvider: @escaping (RDEPUBHighlight) -> String?
|
||
) {
|
||
self.highlights = highlights.sorted { $0.createdAt > $1.createdAt }
|
||
self.theme = theme
|
||
self.sectionTitleProvider = sectionTitleProvider
|
||
super.init(style: .plain)
|
||
title = "标注"
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.accessibilityIdentifier = "epub.reader.highlights.panel"
|
||
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
|
||
tableView.tableFooterView = UIView(frame: .zero)
|
||
tableView.separatorStyle = .none
|
||
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
|
||
navigationItem.rightBarButtonItem = editButtonItem
|
||
editButtonItem.title = "编辑"
|
||
tableView.allowsMultipleSelectionDuringEditing = false
|
||
configureFilterControl()
|
||
applyTheme()
|
||
updateEmptyState()
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||
filteredHighlights.count
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||
let cellIdentifier = "HighlightCell"
|
||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||
let highlight = filteredHighlights[indexPath.row]
|
||
|
||
cell.backgroundColor = .clear
|
||
cell.contentView.backgroundColor = theme.contentBackgroundColor.withAlphaComponent(0.76)
|
||
cell.contentView.layer.cornerRadius = 14
|
||
cell.contentView.layer.masksToBounds = true
|
||
cell.textLabel?.textColor = theme.contentTextColor
|
||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||
cell.textLabel?.numberOfLines = 2
|
||
cell.textLabel?.text = titleText(for: highlight)
|
||
|
||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.65)
|
||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||
cell.detailTextLabel?.numberOfLines = 3
|
||
cell.detailTextLabel?.text = detailText(for: highlight)
|
||
cell.accessoryType = .none
|
||
return cell
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 112 }
|
||
|
||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||
cell.contentView.frame = cell.bounds.inset(by: UIEdgeInsets(top: 6, left: 18, bottom: 6, right: 18))
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
guard !tableView.isEditing else { return }
|
||
tableView.deselectRow(at: indexPath, animated: true)
|
||
onSelectHighlight?(filteredHighlights[indexPath.row])
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||
true
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
||
guard editingStyle == .delete else { return }
|
||
deleteHighlight(filteredHighlights[indexPath.row])
|
||
}
|
||
|
||
private func configureFilterControl() {
|
||
filterControl.selectedSegmentIndex = 0
|
||
filterControl.accessibilityIdentifier = "epub.reader.highlights.filter"
|
||
filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
|
||
navigationItem.titleView = filterControl
|
||
}
|
||
|
||
private func applyTheme() {
|
||
tableView.backgroundColor = theme.toolBackgroundColor
|
||
navigationController?.navigationBar.tintColor = .systemBlue
|
||
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
|
||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
|
||
}
|
||
|
||
private func installEmptyStateLabelIfNeeded() {
|
||
guard emptyStateLabel.superview == nil else { return }
|
||
emptyStateLabel.textAlignment = .center
|
||
emptyStateLabel.numberOfLines = 0
|
||
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
emptyStateLabel.accessibilityIdentifier = "epub.reader.highlights.empty"
|
||
emptyStateLabel.isAccessibilityElement = true
|
||
tableView.addSubview(emptyStateLabel)
|
||
// 固定在可视区域居中,不随内容滚动
|
||
NSLayoutConstraint.activate([
|
||
emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor),
|
||
emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor),
|
||
emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24),
|
||
emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24)
|
||
])
|
||
}
|
||
|
||
private func updateEmptyState() {
|
||
installEmptyStateLabelIfNeeded()
|
||
emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||
emptyStateLabel.text = emptyStateText()
|
||
emptyStateLabel.isHidden = !filteredHighlights.isEmpty
|
||
}
|
||
|
||
@objc private func filterChangedAction() {
|
||
tableView.reloadData()
|
||
updateEmptyState()
|
||
}
|
||
|
||
private func emptyStateText() -> String {
|
||
switch filterControl.selectedSegmentIndex {
|
||
case 1:
|
||
return "暂无注释"
|
||
case 2:
|
||
return "暂无划线"
|
||
default:
|
||
return "暂无标注"
|
||
}
|
||
}
|
||
|
||
private func detailText(for highlight: RDEPUBHighlight) -> String {
|
||
let style = styleDescription(for: highlight)
|
||
let chapter = sectionTitleProvider(highlight)
|
||
let note = normalizedNote(highlight.note)
|
||
let parts = [style, chapter, note].compactMap { $0 }
|
||
if !parts.isEmpty {
|
||
return parts.joined(separator: "\n")
|
||
}
|
||
return highlight.location.href
|
||
}
|
||
|
||
private func titleText(for highlight: RDEPUBHighlight) -> String {
|
||
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if highlight.hasNote {
|
||
return "注释:\(text)"
|
||
}
|
||
return text
|
||
}
|
||
|
||
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
|
||
switch highlight.style {
|
||
case .highlight:
|
||
return highlight.hasNote ? "划线注释" : "划线"
|
||
case .underline:
|
||
return highlight.hasNote ? "划线注释" : "划线"
|
||
}
|
||
}
|
||
|
||
private func deleteHighlight(_ highlight: RDEPUBHighlight) {
|
||
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
|
||
highlights.remove(at: index)
|
||
tableView.reloadData()
|
||
onDeleteHighlight?(highlight)
|
||
updateEmptyState()
|
||
}
|
||
|
||
private func normalizedNote(_ note: String?) -> String? {
|
||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return trimmed.isEmpty ? nil : trimmed
|
||
}
|
||
}
|
||
|
||
final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||
|
||
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
|
||
|
||
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
|
||
|
||
private var bookmarks: [RDEPUBBookmark]
|
||
|
||
private let theme: RDEPUBReaderTheme
|
||
|
||
private let dateFormatter: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||
return formatter
|
||
}()
|
||
|
||
/// 同 highlights 面板:不能用 `tableView.backgroundView`,那样无障碍读不到。
|
||
private let emptyStateLabel = UILabel()
|
||
|
||
init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
|
||
self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
|
||
self.theme = theme
|
||
super.init(style: .plain)
|
||
title = "书签"
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
||
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
||
tableView.tableFooterView = UIView(frame: .zero)
|
||
tableView.separatorStyle = .none
|
||
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
|
||
navigationItem.rightBarButtonItem = editButtonItem
|
||
editButtonItem.title = "编辑"
|
||
applyTheme()
|
||
updateEmptyState()
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||
bookmarks.count
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||
let cellIdentifier = "BookmarkCell"
|
||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||
let bookmark = bookmarks[indexPath.row]
|
||
|
||
cell.backgroundColor = .clear
|
||
cell.contentView.backgroundColor = theme.contentBackgroundColor.withAlphaComponent(0.76)
|
||
cell.contentView.layer.cornerRadius = 14
|
||
cell.contentView.layer.masksToBounds = true
|
||
cell.textLabel?.textColor = theme.contentTextColor
|
||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||
cell.textLabel?.numberOfLines = 2
|
||
cell.textLabel?.text = titleText(for: bookmark)
|
||
|
||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.65)
|
||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||
cell.detailTextLabel?.numberOfLines = 3
|
||
cell.detailTextLabel?.text = detailText(for: bookmark)
|
||
cell.accessoryType = .none
|
||
return cell
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 104 }
|
||
|
||
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
||
guard editingStyle == .delete else { return }
|
||
deleteBookmark(bookmarks[indexPath.row])
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
tableView.deselectRow(at: indexPath, animated: true)
|
||
onSelectBookmark?(bookmarks[indexPath.row])
|
||
}
|
||
|
||
private func applyTheme() {
|
||
tableView.backgroundColor = theme.toolBackgroundColor
|
||
navigationController?.navigationBar.tintColor = .systemBlue
|
||
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
|
||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
||
}
|
||
|
||
private func installEmptyStateLabelIfNeeded() {
|
||
guard emptyStateLabel.superview == nil else { return }
|
||
emptyStateLabel.text = "暂无书签"
|
||
emptyStateLabel.textAlignment = .center
|
||
emptyStateLabel.numberOfLines = 0
|
||
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
emptyStateLabel.accessibilityIdentifier = "epub.reader.bookmarks.empty"
|
||
emptyStateLabel.isAccessibilityElement = true
|
||
tableView.addSubview(emptyStateLabel)
|
||
NSLayoutConstraint.activate([
|
||
emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor),
|
||
emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor),
|
||
emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24),
|
||
emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24)
|
||
])
|
||
}
|
||
|
||
private func updateEmptyState() {
|
||
installEmptyStateLabelIfNeeded()
|
||
emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||
emptyStateLabel.isHidden = !bookmarks.isEmpty
|
||
}
|
||
|
||
private func titleText(for bookmark: RDEPUBBookmark) -> String {
|
||
if let chapterTitle = bookmark.chapterTitle, !chapterTitle.isEmpty {
|
||
return chapterTitle
|
||
}
|
||
return bookmark.location.href
|
||
}
|
||
|
||
private func detailText(for bookmark: RDEPUBBookmark) -> String {
|
||
let parts = [
|
||
dateFormatter.string(from: bookmark.createdAt),
|
||
bookmark.note?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
bookmark.location.href
|
||
].compactMap { value -> String? in
|
||
guard let value, !value.isEmpty else { return nil }
|
||
return value
|
||
}
|
||
return parts.joined(separator: "\n")
|
||
}
|
||
|
||
private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
|
||
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
|
||
bookmarks.remove(at: index)
|
||
tableView.reloadData()
|
||
onDeleteBookmark?(bookmark)
|
||
updateEmptyState()
|
||
}
|
||
}
|