Compare commits
18 Commits
662590027b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c86e482e68 | |||
| ccf0bc876b | |||
| d14ae41706 | |||
| 2d8caf3550 | |||
| 7728a22048 | |||
| ad0ff1ca7a | |||
| 106b60b58f | |||
| 63f5e7ee76 | |||
| cc032110d4 | |||
| f48fd5c3d8 | |||
| 35300e9aa1 | |||
| ad58d9ea5a | |||
| 673e35aec2 | |||
| 74dcaee335 | |||
| 41841896dd | |||
| 0df02d487e | |||
| e176a83b61 | |||
| d77f4da2ee |
@@ -0,0 +1,90 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LearnedRecord: Codable {
|
||||||
|
let word: Word
|
||||||
|
var learnedAt: Date
|
||||||
|
var count: Int = 1
|
||||||
|
var correctCount: Int = 0
|
||||||
|
|
||||||
|
var accuracy: Int {
|
||||||
|
count > 0 ? Int(Double(correctCount) / Double(count) * 100) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, correct: Bool) {
|
||||||
|
if let index = records.firstIndex(where: { $0.word.id == word.id }) {
|
||||||
|
records[index].learnedAt = Date()
|
||||||
|
records[index].count += 1
|
||||||
|
if correct { records[index].correctCount += 1 }
|
||||||
|
} else {
|
||||||
|
var rec = LearnedRecord(word: word, learnedAt: Date())
|
||||||
|
if correct { rec.correctCount = 1 }
|
||||||
|
records.append(rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(for word: Word) -> LearnedRecord? {
|
||||||
|
records.first { $0.word.id == word.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,27 @@ enum QuizGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func recase(_ questions: [QuizQuestion], toUpper: Bool) -> [QuizQuestion] {
|
||||||
|
questions.map { q in
|
||||||
|
let word = q.word
|
||||||
|
let text = toUpper ? word.word.uppercased() : word.word.lowercased()
|
||||||
|
let recased = Word(categoryId: word.categoryId, word: text, phonetic: word.phonetic,
|
||||||
|
meaning: word.meaning, example: word.example, exampleMeaning: word.exampleMeaning)
|
||||||
|
let blanked = q.blankedWord.map { ch -> String in
|
||||||
|
ch == "_" ? "_" : ch.uppercased()
|
||||||
|
}.joined()
|
||||||
|
let blankedWord = toUpper ? blanked.uppercased() : blanked.lowercased()
|
||||||
|
return QuizQuestion(
|
||||||
|
word: recased,
|
||||||
|
prompt: q.prompt,
|
||||||
|
options: q.options,
|
||||||
|
answer: text,
|
||||||
|
blankedWord: blankedWord,
|
||||||
|
blankPositions: q.blankPositions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func promptText(_ word: Word, _ mode: QuizMode) -> String {
|
private static func promptText(_ word: Word, _ mode: QuizMode) -> String {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .enToZh: return word.word
|
case .enToZh: return word.word
|
||||||
|
|||||||
@@ -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)"
|
||||||
@@ -53,7 +63,12 @@ final class CategoryListViewController: UITableViewController {
|
|||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
Haptics.selection()
|
Haptics.selection()
|
||||||
let category = store.categories[indexPath.row]
|
let category = store.categories[indexPath.row]
|
||||||
navigationController?.pushViewController(
|
if let subs = category.subcategories, !subs.isEmpty {
|
||||||
WordListViewController(category: category, colorIndex: indexPath.row), animated: true)
|
let vc = SubcategoryListViewController(category: category, colorIndex: indexPath.row)
|
||||||
|
navigationController?.pushViewController(vc, animated: true)
|
||||||
|
} else {
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
WordListViewController(category: category, colorIndex: indexPath.row), animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class HistoryViewController: UITableViewController {
|
||||||
|
private var sections: [(title: String, items: [LearnedRecord])] = []
|
||||||
|
private var speechObserver: NSObjectProtocol?
|
||||||
|
private let timeFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "zh_CN")
|
||||||
|
f.dateFormat = "HH:mm:ss"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(style: .plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let speechObserver {
|
||||||
|
NotificationCenter.default.removeObserver(speechObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = "学习记录"
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
tableView.register(WordCardCell.self, forCellReuseIdentifier: "cell")
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .systemBackground
|
||||||
|
tableView.contentInset.top = 8
|
||||||
|
|
||||||
|
speechObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
for cell in self?.tableView.visibleCells ?? [] {
|
||||||
|
(cell as? WordCardCell)?.updateSpeakerState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
sections = LearnedStore.shared.sectioned
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
tableView.backgroundView = nil
|
||||||
|
}
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) as! WordCardCell
|
||||||
|
let record = sections[indexPath.section].items[indexPath.row]
|
||||||
|
let time = timeFormatter.string(from: record.learnedAt)
|
||||||
|
cell.configure(with: record.word, count: record.count, accuracy: record.accuracy, time: time)
|
||||||
|
cell.onSpeak = { word in
|
||||||
|
SpeechService.shared.speak(word.word)
|
||||||
|
}
|
||||||
|
cell.onLearn = { [weak self] word in
|
||||||
|
guard let self else { return }
|
||||||
|
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
|
||||||
|
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,6 +218,7 @@ final class QuizViewController: UIViewController {
|
|||||||
|
|
||||||
if isSpelling {
|
if isSpelling {
|
||||||
spellingHintLabel.text = "\(question.word.word.count) 个字母,补全空缺部分"
|
spellingHintLabel.text = "\(question.word.word.count) 个字母,补全空缺部分"
|
||||||
|
spellingView.isUppercase = isUppercase
|
||||||
spellingView.configure(blankedWord: question.blankedWord, blankPositions: question.blankPositions)
|
spellingView.configure(blankedWord: question.blankedWord, blankPositions: question.blankPositions)
|
||||||
if config.isStudyMode {
|
if config.isStudyMode {
|
||||||
nextButton.isHidden = true
|
nextButton.isHidden = true
|
||||||
@@ -358,6 +359,7 @@ final class QuizViewController: UIViewController {
|
|||||||
guard selectedOption == nil else { return }
|
guard selectedOption == nil else { return }
|
||||||
selectedOption = typed
|
selectedOption = typed
|
||||||
let correct = typed.lowercased() == question.answer.lowercased()
|
let correct = typed.lowercased() == question.answer.lowercased()
|
||||||
|
LearnedStore.shared.record(question.word, correct: correct)
|
||||||
if !correct {
|
if !correct {
|
||||||
wrongWords.append(question.word)
|
wrongWords.append(question.word)
|
||||||
}
|
}
|
||||||
@@ -424,7 +426,9 @@ final class QuizViewController: UIViewController {
|
|||||||
isUppercase.toggle()
|
isUppercase.toggle()
|
||||||
UserDefaults.standard.set(isUppercase, forKey: "spelling_uppercase")
|
UserDefaults.standard.set(isUppercase, forKey: "spelling_uppercase")
|
||||||
updateCaseButton()
|
updateCaseButton()
|
||||||
restart()
|
spellingView.isUppercase = isUppercase
|
||||||
|
questions = QuizGenerator.recase(questions, toUpper: isUppercase)
|
||||||
|
spellingView.updateCase(toUpper: isUppercase)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCaseButton() {
|
private func updateCaseButton() {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
private var cursorView: UIView?
|
private var cursorView: UIView?
|
||||||
private var inputEnabled = true
|
private var inputEnabled = true
|
||||||
|
|
||||||
|
var isUppercase = false
|
||||||
|
|
||||||
var isComplete: Bool {
|
var isComplete: Bool {
|
||||||
!filledBlanks.isEmpty && filledBlanks.allSatisfy { !$0.isEmpty }
|
!filledBlanks.isEmpty && filledBlanks.allSatisfy { !$0.isEmpty }
|
||||||
}
|
}
|
||||||
@@ -55,6 +57,7 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
hiddenField.autocapitalizationType = .none
|
hiddenField.autocapitalizationType = .none
|
||||||
hiddenField.spellCheckingType = .no
|
hiddenField.spellCheckingType = .no
|
||||||
hiddenField.keyboardType = .asciiCapable
|
hiddenField.keyboardType = .asciiCapable
|
||||||
|
hiddenField.returnKeyType = .done
|
||||||
hiddenField.frame = .zero
|
hiddenField.frame = .zero
|
||||||
addSubview(hiddenField)
|
addSubview(hiddenField)
|
||||||
|
|
||||||
@@ -132,6 +135,20 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
hiddenField.inputAccessoryView = nil
|
hiddenField.inputAccessoryView = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateCase(toUpper: Bool) {
|
||||||
|
chars = chars.map {
|
||||||
|
guard $0 != "_" else { return $0 }
|
||||||
|
return toUpper ? $0.uppercased() : $0.lowercased()
|
||||||
|
}
|
||||||
|
filledBlanks = filledBlanks.map {
|
||||||
|
$0.isEmpty ? $0 : (toUpper ? $0.uppercased() : $0.lowercased())
|
||||||
|
}
|
||||||
|
for (index, box) in boxViews.enumerated() where chars[index] != "_" {
|
||||||
|
(box.viewWithTag(100) as? UILabel)?.text = chars[index]
|
||||||
|
}
|
||||||
|
refreshBoxes()
|
||||||
|
}
|
||||||
|
|
||||||
func showResultState(correct: Bool) {
|
func showResultState(correct: Bool) {
|
||||||
let color = correct ? Theme.Color.correct : Theme.Color.wrong
|
let color = correct ? Theme.Color.correct : Theme.Color.wrong
|
||||||
for position in blankPositions {
|
for position in blankPositions {
|
||||||
@@ -168,7 +185,8 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
onChange?()
|
onChange?()
|
||||||
}
|
}
|
||||||
} else if let char = string.last, char.isLetter, cursorIndex < blankPositions.count {
|
} else if let char = string.last, char.isLetter, cursorIndex < blankPositions.count {
|
||||||
filledBlanks[cursorIndex] = String(char).lowercased()
|
let converted = isUppercase ? char.uppercased() : char.lowercased()
|
||||||
|
filledBlanks[cursorIndex] = converted
|
||||||
cursorIndex += 1
|
cursorIndex += 1
|
||||||
refreshBoxes()
|
refreshBoxes()
|
||||||
popBox(atBlank: cursorIndex - 1)
|
popBox(atBlank: cursorIndex - 1)
|
||||||
@@ -178,6 +196,12 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
guard isComplete else { return false }
|
||||||
|
onSubmit?()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Boxes
|
// MARK: - Boxes
|
||||||
|
|
||||||
private func rebuildBoxes() {
|
private func rebuildBoxes() {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class SubcategoryListViewController: UITableViewController {
|
||||||
|
private let category: WordCategory
|
||||||
|
private let colorIndex: Int
|
||||||
|
private let subcategories: [Subcategory]
|
||||||
|
|
||||||
|
init(category: WordCategory, colorIndex: Int) {
|
||||||
|
self.category = category
|
||||||
|
self.colorIndex = colorIndex
|
||||||
|
self.subcategories = category.subcategories ?? []
|
||||||
|
super.init(style: .plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = category.name
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .systemBackground
|
||||||
|
tableView.contentInset.top = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
subcategories.count
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CategoryCardCell
|
||||||
|
let sub = subcategories[indexPath.row]
|
||||||
|
cell.configure(with: makeSynthetic(sub), color: Theme.Color.category(at: colorIndex))
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
Haptics.selection()
|
||||||
|
let sub = subcategories[indexPath.row]
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
WordListViewController(category: makeSynthetic(sub), colorIndex: colorIndex), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSynthetic(_ sub: Subcategory) -> WordCategory {
|
||||||
|
WordCategory(id: sub.name, name: sub.name, icon: category.icon,
|
||||||
|
words: sub.words, subcategories: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, accuracy: Int, time: String) {
|
||||||
|
configure(with: word)
|
||||||
|
countLabel.text = "\(time) · \(count)次 · \(accuracy)%"
|
||||||
|
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)
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ final class WordDetailViewController: UIViewController {
|
|||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
title = categoryName
|
title = categoryName
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "学习", style: .plain, target: self, action: #selector(startSpelling))
|
navigationItem.rightBarButtonItems = [
|
||||||
|
UIBarButtonItem(title: "学习", style: .plain, target: self, action: #selector(startSpelling)),
|
||||||
|
UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), style: .plain, target: self, action: #selector(showHistory))
|
||||||
|
]
|
||||||
buildLayout()
|
buildLayout()
|
||||||
updateSpeakState()
|
updateSpeakState()
|
||||||
speechObserver = NotificationCenter.default.addObserver(
|
speechObserver = NotificationCenter.default.addObserver(
|
||||||
@@ -187,6 +190,10 @@ final class WordDetailViewController: UIViewController {
|
|||||||
return card
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func showHistory() {
|
||||||
|
navigationController?.pushViewController(WordHistoryViewController(word: word), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func startSpelling() {
|
@objc private func startSpelling() {
|
||||||
Haptics.selection()
|
Haptics.selection()
|
||||||
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
|
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class WordHistoryViewController: UIViewController {
|
||||||
|
private let word: Word
|
||||||
|
private let record: LearnedRecord?
|
||||||
|
|
||||||
|
init(word: Word) {
|
||||||
|
self.word = word
|
||||||
|
record = LearnedStore.shared.record(for: word)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = "学习记录"
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
buildLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildLayout() {
|
||||||
|
guard let record else {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "暂无学习记录"
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.textColor = .secondaryLabel
|
||||||
|
label.font = .preferredFont(forTextStyle: .body)
|
||||||
|
view.addSubview(label)
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let wordCard = makeWordCard(record)
|
||||||
|
let statsCard = makeStatsCard(record)
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [wordCard, statsCard])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 20
|
||||||
|
|
||||||
|
let scrollView = UIScrollView()
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(stack)
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 20),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||||
|
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeWordCard(_ record: LearnedRecord) -> UIView {
|
||||||
|
let card = UIView()
|
||||||
|
card.backgroundColor = Theme.Color.cardBackground
|
||||||
|
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||||
|
|
||||||
|
let wordLabel = UILabel()
|
||||||
|
wordLabel.text = record.word.word
|
||||||
|
wordLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
|
||||||
|
let phoneticLabel = UILabel()
|
||||||
|
phoneticLabel.text = record.word.phonetic
|
||||||
|
phoneticLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
phoneticLabel.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let meaningLabel = UILabel()
|
||||||
|
meaningLabel.text = record.word.meaning
|
||||||
|
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
meaningLabel.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let textStack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, meaningLabel])
|
||||||
|
textStack.axis = .vertical
|
||||||
|
textStack.spacing = 8
|
||||||
|
|
||||||
|
card.addSubview(textStack)
|
||||||
|
textStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
textStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
||||||
|
textStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
|
||||||
|
textStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
|
||||||
|
textStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
|
||||||
|
])
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStatsCard(_ record: LearnedRecord) -> UIView {
|
||||||
|
let card = UIView()
|
||||||
|
card.backgroundColor = Theme.Color.cardBackground
|
||||||
|
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||||
|
|
||||||
|
let title = UILabel()
|
||||||
|
title.text = "学习统计"
|
||||||
|
title.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
title.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let countLabel = UILabel()
|
||||||
|
countLabel.text = "\(record.count) 次"
|
||||||
|
countLabel.font = .systemFont(ofSize: 36, weight: .bold)
|
||||||
|
|
||||||
|
let accuracyLabel = UILabel()
|
||||||
|
accuracyLabel.text = "正确率 \(record.accuracy)%"
|
||||||
|
accuracyLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
accuracyLabel.textColor = Theme.Color.correct
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
let dateLabel = UILabel()
|
||||||
|
dateLabel.text = "最近学习:\(formatter.string(from: record.learnedAt))"
|
||||||
|
dateLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
dateLabel.textColor = .tertiaryLabel
|
||||||
|
|
||||||
|
let statsStack = UIStackView(arrangedSubviews: [countLabel, accuracyLabel])
|
||||||
|
statsStack.axis = .horizontal
|
||||||
|
statsStack.spacing = 16
|
||||||
|
statsStack.alignment = .lastBaseline
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [title, statsStack, dateLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
|
||||||
|
card.addSubview(stack)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 16),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16)
|
||||||
|
])
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user