学习记录新增学习次数统计,每次拼写验证自动累加
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@ import Foundation
|
|||||||
|
|
||||||
struct LearnedRecord: Codable {
|
struct LearnedRecord: Codable {
|
||||||
let word: Word
|
let word: Word
|
||||||
let learnedAt: Date
|
var learnedAt: Date
|
||||||
|
var count: Int = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
final class LearnedStore {
|
final class LearnedStore {
|
||||||
@@ -26,9 +27,13 @@ final class LearnedStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func record(_ word: Word) {
|
func record(_ word: Word) {
|
||||||
guard !records.contains(where: { $0.word.id == word.id }) else { return }
|
if let index = records.firstIndex(where: { $0.word.id == word.id }) {
|
||||||
|
records[index].learnedAt = Date()
|
||||||
|
records[index].count += 1
|
||||||
|
} else {
|
||||||
records.append(LearnedRecord(word: word, learnedAt: Date()))
|
records.append(LearnedRecord(word: word, learnedAt: Date()))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func isRecorded(_ word: Word) -> Bool {
|
func isRecorded(_ word: Word) -> Bool {
|
||||||
records.contains { $0.word.id == word.id }
|
records.contains { $0.word.id == word.id }
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
final class HistoryViewController: UIViewController {
|
final class HistoryViewController: UITableViewController {
|
||||||
private var selectedDate = Date()
|
private let sections: [(title: String, items: [LearnedRecord])]
|
||||||
private var selectedRecords: [LearnedRecord] = []
|
|
||||||
private var speechObserver: NSObjectProtocol?
|
private var speechObserver: NSObjectProtocol?
|
||||||
|
|
||||||
private let calendarView = UICalendarView()
|
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
|
||||||
private let emptyLabel = UILabel()
|
|
||||||
private let formatter: DateFormatter = {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
|
||||||
f.dateFormat = "yyyy-MM-dd"
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
super.init(nibName: nil, bundle: nil)
|
sections = LearnedStore.shared.sectioned
|
||||||
|
super.init(style: .plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -32,122 +22,52 @@ final class HistoryViewController: UIViewController {
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "学习记录"
|
title = "学习记录"
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
view.backgroundColor = .systemBackground
|
tableView.register(WordCardCell.self, forCellReuseIdentifier: "cell")
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .systemBackground
|
||||||
|
tableView.contentInset.top = 8
|
||||||
|
|
||||||
setupCalendar()
|
if sections.isEmpty {
|
||||||
setupTable()
|
let label = UILabel()
|
||||||
loadRecords(for: selectedDate)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
speechObserver = NotificationCenter.default.addObserver(
|
speechObserver = NotificationCenter.default.addObserver(
|
||||||
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
|
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.updateVisibleSpeakers()
|
for cell in self?.tableView.visibleCells ?? [] {
|
||||||
}
|
(cell as? WordCardCell)?.updateSpeakerState()
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
calendarView.reloadDecorations(forDateComponents: recordDates(), animated: false)
|
|
||||||
loadRecords(for: selectedDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupCalendar() {
|
|
||||||
calendarView.delegate = self
|
|
||||||
calendarView.calendar = .current
|
|
||||||
calendarView.calendar.firstWeekday = 2
|
|
||||||
calendarView.locale = Locale(identifier: "zh_CN")
|
|
||||||
let selection = UICalendarSelectionSingleDate(delegate: self)
|
|
||||||
calendarView.selectionBehavior = selection
|
|
||||||
selection.selectedDate = dateComponents(from: selectedDate)
|
|
||||||
|
|
||||||
view.addSubview(calendarView)
|
|
||||||
calendarView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
calendarView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
||||||
calendarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
calendarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
calendarView.heightAnchor.constraint(equalToConstant: 360)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupTable() {
|
|
||||||
tableView.dataSource = self
|
|
||||||
tableView.delegate = self
|
|
||||||
tableView.register(WordCardCell.self, forCellReuseIdentifier: "cell")
|
|
||||||
tableView.separatorStyle = .none
|
|
||||||
tableView.backgroundColor = .systemBackground
|
|
||||||
|
|
||||||
emptyLabel.text = "当天暂无学习记录"
|
|
||||||
emptyLabel.textAlignment = .center
|
|
||||||
emptyLabel.textColor = .secondaryLabel
|
|
||||||
emptyLabel.font = .preferredFont(forTextStyle: .body)
|
|
||||||
emptyLabel.isHidden = true
|
|
||||||
tableView.backgroundView = emptyLabel
|
|
||||||
|
|
||||||
view.addSubview(tableView)
|
|
||||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
tableView.topAnchor.constraint(equalTo: calendarView.bottomAnchor),
|
|
||||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadRecords(for date: Date) {
|
|
||||||
let dateStr = formatter.string(from: date)
|
|
||||||
let all = LearnedStore.shared.records
|
|
||||||
selectedRecords = all.filter { formatter.string(from: $0.learnedAt) == dateStr }
|
|
||||||
.sorted { $0.learnedAt > $1.learnedAt }
|
|
||||||
emptyLabel.isHidden = !selectedRecords.isEmpty
|
|
||||||
tableView.reloadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func recordDates() -> [DateComponents] {
|
|
||||||
LearnedStore.shared.records.map {
|
|
||||||
dateComponents(from: $0.learnedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dateComponents(from date: Date) -> DateComponents {
|
|
||||||
Calendar.current.dateComponents([.year, .month, .day], from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateVisibleSpeakers() {
|
|
||||||
for cell in tableView.visibleCells.compactMap({ $0 as? WordCardCell }) {
|
|
||||||
cell.updateSpeakerState()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HistoryViewController: UICalendarViewDelegate {
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
func calendarView(_ calendarView: UICalendarView, decorationFor dateComponents: DateComponents) -> UICalendarView.Decoration? {
|
sections.count
|
||||||
let dates = recordDates()
|
|
||||||
if dates.contains(where: { $0.year == dateComponents.year && $0.month == dateComponents.month && $0.day == dateComponents.day }) {
|
|
||||||
return .default(color: Theme.Color.accent, size: .small)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HistoryViewController: UICalendarSelectionSingleDateDelegate {
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
func dateSelection(_ selection: UICalendarSelectionSingleDate, didSelectDate dateComponents: DateComponents?) {
|
sections[section].title
|
||||||
guard let dateComponents,
|
|
||||||
let date = Calendar.current.date(from: dateComponents) else { return }
|
|
||||||
selectedDate = date
|
|
||||||
loadRecords(for: date)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HistoryViewController: UITableViewDataSource, UITableViewDelegate {
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
sections[section].items.count
|
||||||
selectedRecords.count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! WordCardCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! WordCardCell
|
||||||
let record = selectedRecords[indexPath.row]
|
let record = sections[indexPath.section].items[indexPath.row]
|
||||||
cell.configure(with: record.word)
|
cell.configure(with: record.word, count: record.count)
|
||||||
cell.onSpeak = { word in
|
cell.onSpeak = { word in
|
||||||
SpeechService.shared.speak(word.word)
|
SpeechService.shared.speak(word.word)
|
||||||
}
|
}
|
||||||
@@ -159,9 +79,9 @@ extension HistoryViewController: UITableViewDataSource, UITableViewDelegate {
|
|||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
Haptics.selection()
|
Haptics.selection()
|
||||||
let record = selectedRecords[indexPath.row]
|
let record = sections[indexPath.section].items[indexPath.row]
|
||||||
let detail = WordDetailViewController(word: record.word, categoryName: "")
|
let detail = WordDetailViewController(word: record.word, categoryName: "")
|
||||||
navigationController?.pushViewController(detail, animated: true)
|
navigationController?.pushViewController(detail, animated: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ final class WordCardCell: UITableViewCell {
|
|||||||
private let wordLabel = UILabel()
|
private let wordLabel = UILabel()
|
||||||
private let phoneticLabel = UILabel()
|
private let phoneticLabel = UILabel()
|
||||||
private let meaningLabel = UILabel()
|
private let meaningLabel = UILabel()
|
||||||
|
private let countLabel = UILabel()
|
||||||
private let learnButton = UIButton(type: .system)
|
private let learnButton = UIButton(type: .system)
|
||||||
private let speakerButton = UIButton(type: .system)
|
private let speakerButton = UIButton(type: .system)
|
||||||
private var word: Word?
|
private var word: Word?
|
||||||
@@ -37,6 +38,10 @@ final class WordCardCell: UITableViewCell {
|
|||||||
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
meaningLabel.textColor = .secondaryLabel
|
meaningLabel.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
countLabel.font = .preferredFont(forTextStyle: .caption2)
|
||||||
|
countLabel.textColor = Theme.Color.accent
|
||||||
|
countLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
|
||||||
let textStack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, meaningLabel])
|
let textStack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, meaningLabel])
|
||||||
textStack.axis = .vertical
|
textStack.axis = .vertical
|
||||||
textStack.spacing = 8
|
textStack.spacing = 8
|
||||||
@@ -57,7 +62,7 @@ final class WordCardCell: UITableViewCell {
|
|||||||
}, for: .touchUpInside)
|
}, for: .touchUpInside)
|
||||||
speakerButton.setContentHuggingPriority(.required, for: .horizontal)
|
speakerButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
|
||||||
let row = UIStackView(arrangedSubviews: [textStack, learnButton, speakerButton])
|
let row = UIStackView(arrangedSubviews: [textStack, countLabel, learnButton, speakerButton])
|
||||||
row.axis = .horizontal
|
row.axis = .horizontal
|
||||||
row.alignment = .center
|
row.alignment = .center
|
||||||
row.spacing = 8
|
row.spacing = 8
|
||||||
@@ -83,9 +88,16 @@ final class WordCardCell: UITableViewCell {
|
|||||||
wordLabel.text = word.word
|
wordLabel.text = word.word
|
||||||
phoneticLabel.text = word.phonetic
|
phoneticLabel.text = word.phonetic
|
||||||
meaningLabel.text = word.meaning
|
meaningLabel.text = word.meaning
|
||||||
|
countLabel.isHidden = true
|
||||||
updateSpeakerState()
|
updateSpeakerState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configure(with word: Word, count: Int) {
|
||||||
|
configure(with: word)
|
||||||
|
countLabel.text = "\(count)次"
|
||||||
|
countLabel.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
func updateSpeakerState() {
|
func updateSpeakerState() {
|
||||||
guard let word else { return }
|
guard let word else { return }
|
||||||
let speaking = SpeechService.shared.isSpeaking(word.word)
|
let speaking = SpeechService.shared.isSpeaking(word.word)
|
||||||
|
|||||||
Reference in New Issue
Block a user