新增学习记录功能,拼写验证后自动记录,首页可查看时间线
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LearnedRecord: Codable {
|
||||||
|
let word: Word
|
||||||
|
let learnedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
final class LearnedStore {
|
||||||
|
static let shared = LearnedStore()
|
||||||
|
private let key = "learned_records"
|
||||||
|
|
||||||
|
private(set) var records: [LearnedRecord] {
|
||||||
|
didSet {
|
||||||
|
guard let data = try? JSONEncoder().encode(records) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: key),
|
||||||
|
let decoded = try? JSONDecoder().decode([LearnedRecord].self, from: data) {
|
||||||
|
records = decoded
|
||||||
|
} else {
|
||||||
|
records = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(_ word: Word) {
|
||||||
|
guard !records.contains(where: { $0.word.id == word.id }) else { return }
|
||||||
|
records.append(LearnedRecord(word: word, learnedAt: Date()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecorded(_ word: Word) -> Bool {
|
||||||
|
records.contains { $0.word.id == word.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
var sectioned: [(title: String, items: [LearnedRecord])] {
|
||||||
|
let sorted = records.sorted { $0.learnedAt > $1.learnedAt }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
|
||||||
|
var groups: [(String, [LearnedRecord])] = []
|
||||||
|
var seen: Set<String> = []
|
||||||
|
for record in sorted {
|
||||||
|
let dateStr = formatter.string(from: record.learnedAt)
|
||||||
|
let title = sectionTitle(dateStr)
|
||||||
|
if !seen.contains(title) {
|
||||||
|
groups.append((title, []))
|
||||||
|
seen.insert(title)
|
||||||
|
}
|
||||||
|
if let index = groups.firstIndex(where: { $0.0 == title }) {
|
||||||
|
groups[index].1.append(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionTitle(_ dateStr: String) -> String {
|
||||||
|
let today = DateFormatter()
|
||||||
|
today.locale = Locale(identifier: "zh_CN")
|
||||||
|
today.dateFormat = "yyyy-MM-dd"
|
||||||
|
let todayStr = today.string(from: Date())
|
||||||
|
if dateStr == todayStr { return "今天" }
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if let yesterday = calendar.date(byAdding: .day, value: -1, to: Date()) {
|
||||||
|
let yesterdayStr = today.string(from: yesterday)
|
||||||
|
if dateStr == yesterdayStr { return "昨天" }
|
||||||
|
}
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ final class CategoryListViewController: UITableViewController {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "背单词"
|
title = "背单词"
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "list.bullet.clipboard"),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(showHistory)
|
||||||
|
)
|
||||||
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
|
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.backgroundColor = .systemBackground
|
tableView.backgroundColor = .systemBackground
|
||||||
@@ -23,6 +29,10 @@ final class CategoryListViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func showHistory() {
|
||||||
|
navigationController?.pushViewController(HistoryViewController(), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func showError(_ message: String) {
|
private func showError(_ message: String) {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = "词库加载失败\n\(message)"
|
label.text = "词库加载失败\n\(message)"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class HistoryViewController: UITableViewController {
|
||||||
|
private let sections: [(title: String, items: [LearnedRecord])]
|
||||||
|
|
||||||
|
init() {
|
||||||
|
sections = LearnedStore.shared.sectioned
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = "学习记录"
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
||||||
|
tableView.backgroundColor = .systemBackground
|
||||||
|
updateEmptyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEmptyState() {
|
||||||
|
if sections.isEmpty {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "暂无学习记录"
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.textColor = .secondaryLabel
|
||||||
|
label.font = .preferredFont(forTextStyle: .body)
|
||||||
|
let bg = UIView()
|
||||||
|
bg.addSubview(label)
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
label.centerXAnchor.constraint(equalTo: bg.centerXAnchor),
|
||||||
|
label.centerYAnchor.constraint(equalTo: bg.centerYAnchor)
|
||||||
|
])
|
||||||
|
tableView.backgroundView = bg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
|
sections.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
sections[section].title
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
sections[section].items.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
||||||
|
let record = sections[indexPath.section].items[indexPath.row]
|
||||||
|
var config = cell.defaultContentConfiguration()
|
||||||
|
config.text = record.word.word
|
||||||
|
config.secondaryText = "\(record.word.phonetic) \(record.word.meaning)"
|
||||||
|
config.image = UIImage(systemName: "text.book.closed")
|
||||||
|
config.imageProperties.tintColor = Theme.Color.accent
|
||||||
|
cell.contentConfiguration = config
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
Haptics.selection()
|
||||||
|
let record = sections[indexPath.section].items[indexPath.row]
|
||||||
|
let detail = WordDetailViewController(word: record.word, categoryName: "")
|
||||||
|
navigationController?.pushViewController(detail, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -357,6 +357,7 @@ final class QuizViewController: UIViewController {
|
|||||||
private func submitSpelling(_ typed: String, for question: QuizQuestion) {
|
private func submitSpelling(_ typed: String, for question: QuizQuestion) {
|
||||||
guard selectedOption == nil else { return }
|
guard selectedOption == nil else { return }
|
||||||
selectedOption = typed
|
selectedOption = typed
|
||||||
|
LearnedStore.shared.record(question.word)
|
||||||
let correct = typed.lowercased() == question.answer.lowercased()
|
let correct = typed.lowercased() == question.answer.lowercased()
|
||||||
if !correct {
|
if !correct {
|
||||||
wrongWords.append(question.word)
|
wrongWords.append(question.word)
|
||||||
|
|||||||
Reference in New Issue
Block a user