447 lines
19 KiB
Swift
447 lines
19 KiB
Swift
import UIKit
|
|
|
|
final class QuizViewController: UIViewController {
|
|
private let config: QuizConfig
|
|
private let speech = SpeechService.shared
|
|
|
|
private var questions: [QuizQuestion]
|
|
private var currentIndex = 0
|
|
private var selectedOption: String?
|
|
private var wrongWords: [Word] = []
|
|
private var startDate = Date.now
|
|
|
|
private let progressView = UIProgressView(progressViewStyle: .default)
|
|
private let countLabel = UILabel()
|
|
private let promptLabel = UILabel()
|
|
private let phoneticLabel = UILabel()
|
|
private let speakerButton = UIButton(type: .system)
|
|
private let optionsStack = UIStackView()
|
|
private let spellingView = SpellingInputView()
|
|
private let dictationView = DictationInputView()
|
|
private let spellingHintLabel = UILabel()
|
|
private let resultLabel = UILabel()
|
|
private let resultPhoneticLabel = UILabel()
|
|
private let nextButton = UIButton(type: .system)
|
|
private var optionButtons: [UIButton] = []
|
|
private var optionIcons: [UIImageView] = []
|
|
|
|
private let promptStack = UIStackView()
|
|
private let inputStack = UIStackView()
|
|
private let resultStack = UIStackView()
|
|
private let navSpeakerItem = UIBarButtonItem(
|
|
image: UIImage(systemName: "speaker.wave.2.fill"),
|
|
style: .plain,
|
|
target: nil,
|
|
action: nil
|
|
)
|
|
|
|
private var current: QuizQuestion? {
|
|
questions.indices.contains(currentIndex) ? questions[currentIndex] : nil
|
|
}
|
|
|
|
init(config: QuizConfig) {
|
|
self.config = config
|
|
self.questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .systemBackground
|
|
title = config.mode.rawValue
|
|
navigationItem.largeTitleDisplayMode = .never
|
|
buildLayout()
|
|
showQuestion()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
if config.mode == .spelling, selectedOption == nil {
|
|
spellingView.activate()
|
|
} else if config.mode == .dictation, selectedOption == nil {
|
|
dictationView.activate()
|
|
}
|
|
}
|
|
|
|
// MARK: - Layout
|
|
|
|
private func buildLayout() {
|
|
progressView.transform = CGAffineTransform(scaleX: 1, y: 0.75)
|
|
progressView.trackTintColor = .tertiarySystemFill
|
|
progressView.progressTintColor = Theme.Color.accent
|
|
|
|
countLabel.font = .preferredFont(forTextStyle: .caption1)
|
|
countLabel.textColor = .secondaryLabel
|
|
countLabel.textAlignment = .center
|
|
|
|
promptLabel.font = config.mode == .enToZh
|
|
? Theme.Font.wordHero(size: 40)
|
|
: .systemFont(ofSize: 34, weight: .bold)
|
|
promptLabel.textAlignment = .center
|
|
promptLabel.numberOfLines = 0
|
|
|
|
phoneticLabel.font = .preferredFont(forTextStyle: .title3)
|
|
phoneticLabel.textColor = .secondaryLabel
|
|
phoneticLabel.textAlignment = .center
|
|
|
|
speakerButton.setImage(UIImage(systemName: "speaker.wave.2.fill",
|
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)),
|
|
for: .normal)
|
|
speakerButton.tintColor = Theme.Color.accent
|
|
speakerButton.addAction(UIAction { [weak self] _ in
|
|
guard let self, let question = current else { return }
|
|
speech.speak(question.prompt)
|
|
}, for: .touchUpInside)
|
|
|
|
optionsStack.axis = .vertical
|
|
optionsStack.spacing = 12
|
|
|
|
spellingHintLabel.font = .preferredFont(forTextStyle: .caption1)
|
|
spellingHintLabel.textColor = .tertiaryLabel
|
|
spellingHintLabel.textAlignment = .center
|
|
|
|
resultLabel.font = .preferredFont(forTextStyle: .title2).withWeight(.bold)
|
|
resultLabel.textAlignment = .center
|
|
|
|
resultPhoneticLabel.font = .preferredFont(forTextStyle: .subheadline)
|
|
resultPhoneticLabel.textColor = .secondaryLabel
|
|
resultPhoneticLabel.textAlignment = .center
|
|
|
|
var nextConfig = UIButton.Configuration.borderedProminent()
|
|
nextConfig.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
|
|
nextConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
|
nextButton.configuration = nextConfig
|
|
nextButton.addAction(UIAction { [weak self] _ in self?.primaryButtonTapped() }, for: .touchUpInside)
|
|
|
|
navSpeakerItem.target = self
|
|
navSpeakerItem.action = #selector(speakCurrentWord)
|
|
navigationItem.rightBarButtonItem = navSpeakerItem
|
|
|
|
let headerStack = UIStackView(arrangedSubviews: [progressView, countLabel])
|
|
headerStack.axis = .vertical
|
|
headerStack.spacing = 8
|
|
|
|
promptStack.axis = .vertical
|
|
promptStack.spacing = 12
|
|
promptStack.alignment = .center
|
|
[promptLabel, phoneticLabel, speakerButton].forEach { promptStack.addArrangedSubview($0) }
|
|
|
|
inputStack.axis = .vertical
|
|
inputStack.spacing = 16
|
|
[optionsStack, spellingView, dictationView, spellingHintLabel].forEach { inputStack.addArrangedSubview($0) }
|
|
|
|
resultStack.axis = .vertical
|
|
resultStack.spacing = 12
|
|
[resultLabel, resultPhoneticLabel, nextButton].forEach { resultStack.addArrangedSubview($0) }
|
|
|
|
let mainStack = UIStackView(arrangedSubviews: [headerStack, promptStack, inputStack, resultStack, UIView()])
|
|
mainStack.axis = .vertical
|
|
mainStack.spacing = 24
|
|
|
|
let scrollView = UIScrollView()
|
|
scrollView.alwaysBounceVertical = true
|
|
view.addSubview(scrollView)
|
|
scrollView.addSubview(mainStack)
|
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
mainStack.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.safeAreaLayoutGuide.bottomAnchor),
|
|
|
|
mainStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
|
|
mainStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
|
mainStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
|
mainStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
|
mainStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32),
|
|
])
|
|
}
|
|
|
|
// MARK: - Question flow
|
|
|
|
private func showQuestion() {
|
|
guard let question = current else { return }
|
|
selectedOption = nil
|
|
|
|
progressView.setProgress(Float(currentIndex) / Float(questions.count), animated: true)
|
|
countLabel.text = "\(currentIndex + 1) / \(questions.count)"
|
|
promptLabel.text = question.prompt
|
|
phoneticLabel.isHidden = config.mode != .enToZh
|
|
speakerButton.isHidden = config.mode != .enToZh
|
|
phoneticLabel.text = question.word.phonetic
|
|
|
|
resultLabel.isHidden = true
|
|
resultPhoneticLabel.isHidden = true
|
|
|
|
navigationItem.rightBarButtonItem = config.mode == .zhToEn ? nil : navSpeakerItem
|
|
|
|
let isSpelling = config.mode == .spelling
|
|
let isDictation = config.mode == .dictation
|
|
let isChoice = !isSpelling && !isDictation
|
|
optionsStack.isHidden = !isChoice
|
|
spellingView.isHidden = !isSpelling
|
|
dictationView.isHidden = !isDictation
|
|
spellingHintLabel.isHidden = !isSpelling
|
|
|
|
if isSpelling {
|
|
spellingHintLabel.text = "\(question.word.word.count) 个字母,补全空缺部分"
|
|
spellingView.configure(blankedWord: question.blankedWord, blankPositions: question.blankPositions)
|
|
spellingView.onChange = { [weak self] in
|
|
guard let self else { return }
|
|
nextButton.isEnabled = spellingView.isComplete
|
|
}
|
|
nextButton.configuration?.title = "确认"
|
|
nextButton.isEnabled = false
|
|
nextButton.isHidden = false
|
|
spellingView.activate()
|
|
} else if isDictation {
|
|
dictationView.reset()
|
|
dictationView.onChange = { [weak self] in
|
|
guard let self else { return }
|
|
nextButton.isEnabled = !dictationView.isEmpty
|
|
}
|
|
dictationView.onSubmit = { [weak self] in
|
|
guard let self else { return }
|
|
primaryButtonTapped()
|
|
}
|
|
nextButton.configuration?.title = "确认"
|
|
nextButton.isEnabled = false
|
|
nextButton.isHidden = false
|
|
dictationView.activate()
|
|
} else {
|
|
nextButton.isHidden = true
|
|
buildOptionButtons(for: question)
|
|
}
|
|
}
|
|
|
|
private func buildOptionButtons(for question: QuizQuestion) {
|
|
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
|
optionButtons = []
|
|
optionIcons = []
|
|
for option in question.options {
|
|
let button = UIButton(type: .system)
|
|
var buttonConfig = UIButton.Configuration.bordered()
|
|
buttonConfig.title = option
|
|
buttonConfig.titleAlignment = .leading
|
|
buttonConfig.baseForegroundColor = .label
|
|
buttonConfig.background.backgroundColor = Theme.Color.cardBackground
|
|
buttonConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
|
buttonConfig.background.strokeColor = .separator
|
|
buttonConfig.background.strokeWidth = 1
|
|
if self.config.mode == .zhToEn, let w = self.config.words.first(where: { $0.word == option }) {
|
|
buttonConfig.subtitle = w.phonetic
|
|
}
|
|
buttonConfig.titleTextAttributesTransformer = .init { incoming in
|
|
var outgoing = incoming
|
|
outgoing.font = .preferredFont(forTextStyle: .headline)
|
|
return outgoing
|
|
}
|
|
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 44)
|
|
button.configuration = buttonConfig
|
|
button.contentHorizontalAlignment = .leading
|
|
|
|
let icon = UIImageView()
|
|
icon.isHidden = true
|
|
icon.contentMode = .scaleAspectFit
|
|
button.addSubview(icon)
|
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
icon.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -14),
|
|
icon.centerYAnchor.constraint(equalTo: button.centerYAnchor)
|
|
])
|
|
|
|
button.addAction(UIAction { [weak self] _ in
|
|
self?.selectOption(option, for: question)
|
|
}, for: .touchUpInside)
|
|
optionsStack.addArrangedSubview(button)
|
|
optionButtons.append(button)
|
|
optionIcons.append(icon)
|
|
}
|
|
}
|
|
|
|
private func selectOption(_ option: String, for question: QuizQuestion) {
|
|
guard selectedOption == nil else { return }
|
|
selectedOption = option
|
|
let correct = option == question.answer
|
|
if !correct {
|
|
wrongWords.append(question.word)
|
|
}
|
|
correct ? Haptics.correct() : Haptics.wrong()
|
|
|
|
for (index, button) in optionButtons.enumerated() {
|
|
let value = question.options[index]
|
|
button.isUserInteractionEnabled = false
|
|
var buttonConfig = button.configuration
|
|
if value == question.answer {
|
|
buttonConfig?.background.strokeColor = Theme.Color.correct
|
|
buttonConfig?.background.strokeWidth = 2
|
|
buttonConfig?.background.backgroundColor = Theme.Color.correct.withAlphaComponent(0.12)
|
|
buttonConfig?.baseForegroundColor = Theme.Color.correct
|
|
optionIcons[index].image = UIImage(systemName: "checkmark.circle.fill",
|
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))
|
|
optionIcons[index].tintColor = Theme.Color.correct
|
|
optionIcons[index].isHidden = false
|
|
} else if value == option {
|
|
buttonConfig?.background.strokeColor = Theme.Color.wrong
|
|
buttonConfig?.background.strokeWidth = 2
|
|
buttonConfig?.background.backgroundColor = Theme.Color.wrong.withAlphaComponent(0.12)
|
|
buttonConfig?.baseForegroundColor = Theme.Color.wrong
|
|
optionIcons[index].image = UIImage(systemName: "xmark.circle.fill",
|
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))
|
|
optionIcons[index].tintColor = Theme.Color.wrong
|
|
optionIcons[index].isHidden = false
|
|
} else {
|
|
button.alpha = 0.4
|
|
}
|
|
button.configuration = buttonConfig
|
|
}
|
|
for icon in optionIcons where !icon.isHidden {
|
|
icon.transform = CGAffineTransform(scaleX: 0.4, y: 0.4)
|
|
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.5,
|
|
initialSpringVelocity: 0, options: .allowUserInteraction) {
|
|
icon.transform = .identity
|
|
}
|
|
}
|
|
navigationItem.rightBarButtonItem = navSpeakerItem
|
|
showResult(correct, for: question)
|
|
}
|
|
|
|
private func submitSpelling(_ typed: String, for question: QuizQuestion) {
|
|
guard selectedOption == nil else { return }
|
|
selectedOption = typed
|
|
let correct = typed == question.answer
|
|
if !correct {
|
|
wrongWords.append(question.word)
|
|
}
|
|
correct ? Haptics.correct() : Haptics.wrong()
|
|
spellingView.deactivate()
|
|
spellingView.showResultState(correct: correct)
|
|
showResult(correct, for: question)
|
|
}
|
|
|
|
private func submitDictation(_ typed: String, for question: QuizQuestion) {
|
|
guard selectedOption == nil else { return }
|
|
selectedOption = typed
|
|
let correct = typed == question.answer
|
|
if !correct {
|
|
wrongWords.append(question.word)
|
|
}
|
|
correct ? Haptics.correct() : Haptics.wrong()
|
|
dictationView.deactivate()
|
|
dictationView.showResultState(correct: correct)
|
|
showResult(correct, for: question)
|
|
}
|
|
|
|
private func showResult(_ correct: Bool, for question: QuizQuestion) {
|
|
if config.mode == .spelling || config.mode == .dictation {
|
|
resultLabel.text = correct ? "正确" : "正确答案:\(question.answer)"
|
|
resultPhoneticLabel.text = question.word.phonetic
|
|
resultPhoneticLabel.isHidden = false
|
|
} else {
|
|
resultLabel.text = correct ? "回答正确" : "正确答案:\(question.answer)"
|
|
}
|
|
resultLabel.textColor = correct ? Theme.Color.correct : Theme.Color.wrong
|
|
resultLabel.isHidden = false
|
|
|
|
nextButton.configuration?.title = currentIndex + 1 < questions.count ? "下一题" : "查看成绩"
|
|
nextButton.isEnabled = true
|
|
nextButton.isHidden = false
|
|
}
|
|
|
|
@objc private func speakCurrentWord() {
|
|
guard let question = current else { return }
|
|
speech.speak(question.word.word)
|
|
}
|
|
|
|
private func primaryButtonTapped() {
|
|
if config.mode == .spelling, selectedOption == nil {
|
|
guard let question = current, spellingView.isComplete else { return }
|
|
submitSpelling(spellingView.typedWord(), for: question)
|
|
} else if config.mode == .dictation, selectedOption == nil {
|
|
guard let question = current, !dictationView.isEmpty else { return }
|
|
submitDictation(dictationView.text, for: question)
|
|
} else {
|
|
next()
|
|
}
|
|
}
|
|
|
|
private func next() {
|
|
nextButton.isEnabled = false
|
|
if currentIndex + 1 < questions.count {
|
|
currentIndex += 1
|
|
animateQuestionSwap()
|
|
} else {
|
|
showQuizResult()
|
|
}
|
|
}
|
|
|
|
private func animateQuestionSwap() {
|
|
let stacks = [promptStack, inputStack, resultStack]
|
|
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseIn) {
|
|
stacks.forEach { $0.alpha = 0 }
|
|
} completion: { _ in
|
|
self.showQuestion()
|
|
stacks.forEach { $0.transform = CGAffineTransform(translationX: 0, y: 12) }
|
|
UIView.animate(withDuration: 0.3, delay: 0,
|
|
options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState]) {
|
|
stacks.forEach {
|
|
$0.alpha = 1
|
|
$0.transform = .identity
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func showQuizResult() {
|
|
navigationItem.rightBarButtonItem = nil
|
|
progressView.setProgress(1, animated: true)
|
|
let resultView = QuizResultView(
|
|
total: questions.count,
|
|
wrongWords: wrongWords,
|
|
duration: Date.now.timeIntervalSince(startDate),
|
|
onRestart: { [weak self] in self?.restart() },
|
|
onExit: { [weak self] in self?.navigationController?.popViewController(animated: true) },
|
|
onSelectWord: { [weak self] word in
|
|
self?.navigationController?.pushViewController(WordDetailViewController(word: word), animated: true)
|
|
}
|
|
)
|
|
resultView.frame = view.bounds
|
|
resultView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
resultView.alpha = 0
|
|
resultView.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
|
view.addSubview(resultView)
|
|
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.8,
|
|
initialSpringVelocity: 0, options: .curveEaseOut) {
|
|
resultView.alpha = 1
|
|
resultView.transform = .identity
|
|
}
|
|
}
|
|
|
|
private func restart() {
|
|
view.subviews.compactMap { $0 as? QuizResultView }.forEach { $0.removeFromSuperview() }
|
|
questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
|
|
currentIndex = 0
|
|
wrongWords = []
|
|
startDate = .now
|
|
showQuestion()
|
|
if config.mode == .spelling {
|
|
spellingView.activate()
|
|
} else if config.mode == .dictation {
|
|
dictationView.activate()
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UIFont {
|
|
func withWeight(_ weight: UIFont.Weight) -> UIFont {
|
|
let descriptor = fontDescriptor.addingAttributes([
|
|
.traits: [UIFontDescriptor.TraitKey.weight: weight]
|
|
])
|
|
return UIFont(descriptor: descriptor, size: pointSize)
|
|
}
|
|
}
|