2d8caf3550
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
219 lines
9.3 KiB
Swift
219 lines
9.3 KiB
Swift
import SafariServices
|
|
import UIKit
|
|
|
|
final class WordDetailViewController: UIViewController {
|
|
private let word: Word
|
|
private let categoryName: String
|
|
private let speech = SpeechService.shared
|
|
|
|
private let speakButton = UIButton(type: .system)
|
|
private let exampleSpeakButton = UIButton(type: .system)
|
|
private let contentStack = UIStackView()
|
|
private var speechObserver: NSObjectProtocol?
|
|
|
|
init(word: Word, categoryName: String = "") {
|
|
self.word = word
|
|
self.categoryName = categoryName
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
deinit {
|
|
if let speechObserver {
|
|
NotificationCenter.default.removeObserver(speechObserver)
|
|
}
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .systemBackground
|
|
title = categoryName
|
|
navigationItem.largeTitleDisplayMode = .never
|
|
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()
|
|
updateSpeakState()
|
|
speechObserver = NotificationCenter.default.addObserver(
|
|
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.updateSpeakState()
|
|
}
|
|
}
|
|
|
|
private func updateSpeakState() {
|
|
let wordSpeaking = speech.isSpeaking(word.word)
|
|
speakButton.configuration?.image = UIImage(
|
|
systemName: wordSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill",
|
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))
|
|
speakButton.isEnabled = !wordSpeaking
|
|
|
|
let exampleSpeaking = speech.isSpeaking(word.example)
|
|
exampleSpeakButton.configuration?.image = UIImage(
|
|
systemName: exampleSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2",
|
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))
|
|
exampleSpeakButton.isEnabled = !exampleSpeaking
|
|
}
|
|
|
|
private func buildLayout() {
|
|
let scrollView = UIScrollView()
|
|
scrollView.alwaysBounceVertical = true
|
|
view.addSubview(scrollView)
|
|
scrollView.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)
|
|
])
|
|
|
|
contentStack.axis = .vertical
|
|
contentStack.spacing = 32
|
|
scrollView.addSubview(contentStack)
|
|
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 24),
|
|
contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
|
contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
|
contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
|
contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
|
])
|
|
|
|
contentStack.addArrangedSubview(makeHeader())
|
|
contentStack.addArrangedSubview(makeCard())
|
|
}
|
|
|
|
private func makeHeader() -> UIView {
|
|
let wordLabel = UILabel()
|
|
wordLabel.text = word.word
|
|
wordLabel.font = Theme.Font.wordHero()
|
|
wordLabel.textAlignment = .center
|
|
|
|
let phoneticLabel = UILabel()
|
|
phoneticLabel.text = word.phonetic
|
|
phoneticLabel.font = .preferredFont(forTextStyle: .title3)
|
|
phoneticLabel.textColor = .secondaryLabel
|
|
|
|
var buttonConfig = UIButton.Configuration.filled()
|
|
buttonConfig.baseBackgroundColor = Theme.Color.accent
|
|
buttonConfig.baseForegroundColor = .white
|
|
buttonConfig.cornerStyle = .capsule
|
|
speakButton.configuration = buttonConfig
|
|
speakButton.addAction(UIAction { [weak self] _ in
|
|
guard let self else { return }
|
|
speech.speak(word.word)
|
|
}, for: .touchUpInside)
|
|
speakButton.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
speakButton.widthAnchor.constraint(equalToConstant: 56),
|
|
speakButton.heightAnchor.constraint(equalToConstant: 56)
|
|
])
|
|
|
|
let stack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, speakButton])
|
|
stack.axis = .vertical
|
|
stack.spacing = 16
|
|
stack.alignment = .center
|
|
return stack
|
|
}
|
|
|
|
private func makeCard() -> UIView {
|
|
let card = UIView()
|
|
card.backgroundColor = Theme.Color.cardBackground
|
|
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
|
|
|
let meaningTitle = makeCaption("释义")
|
|
let meaningLabel = UILabel()
|
|
meaningLabel.text = word.meaning
|
|
meaningLabel.font = .preferredFont(forTextStyle: .title2)
|
|
meaningLabel.numberOfLines = 0
|
|
|
|
var searchConfig = UIButton.Configuration.plain()
|
|
searchConfig.baseForegroundColor = Theme.Color.accent
|
|
searchConfig.image = UIImage(systemName: "magnifyingglass",
|
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))
|
|
let searchButton = UIButton(type: .system)
|
|
searchButton.configuration = searchConfig
|
|
searchButton.addAction(UIAction { [weak self] _ in self?.searchMeaning() }, for: .touchUpInside)
|
|
searchButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
let meaningRow = UIStackView(arrangedSubviews: [meaningLabel, searchButton])
|
|
meaningRow.axis = .horizontal
|
|
meaningRow.alignment = .top
|
|
meaningRow.spacing = 8
|
|
|
|
let divider = HairlineView()
|
|
|
|
let exampleTitle = makeCaption("例句")
|
|
let exampleLabel = UILabel()
|
|
exampleLabel.text = word.example
|
|
exampleLabel.numberOfLines = 0
|
|
let exampleMeaningLabel = UILabel()
|
|
exampleMeaningLabel.text = word.exampleMeaning
|
|
exampleMeaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
|
exampleMeaningLabel.textColor = .secondaryLabel
|
|
exampleMeaningLabel.numberOfLines = 0
|
|
|
|
var ghostConfig = UIButton.Configuration.plain()
|
|
ghostConfig.baseForegroundColor = Theme.Color.accent
|
|
exampleSpeakButton.configuration = ghostConfig
|
|
exampleSpeakButton.addAction(UIAction { [weak self] _ in
|
|
guard let self else { return }
|
|
speech.speak(word.example)
|
|
}, for: .touchUpInside)
|
|
exampleSpeakButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
let exampleTextStack = UIStackView(arrangedSubviews: [exampleLabel, exampleMeaningLabel])
|
|
exampleTextStack.axis = .vertical
|
|
exampleTextStack.spacing = 4
|
|
let exampleRow = UIStackView(arrangedSubviews: [exampleTextStack, exampleSpeakButton])
|
|
exampleRow.axis = .horizontal
|
|
exampleRow.alignment = .top
|
|
exampleRow.spacing = 8
|
|
|
|
let stack = UIStackView(arrangedSubviews: [meaningTitle, meaningRow, divider, exampleTitle, exampleRow])
|
|
stack.axis = .vertical
|
|
stack.spacing = 16
|
|
stack.setCustomSpacing(6, after: meaningTitle)
|
|
stack.setCustomSpacing(6, after: exampleTitle)
|
|
|
|
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
|
|
}
|
|
|
|
@objc private func showHistory() {
|
|
navigationController?.pushViewController(WordHistoryViewController(word: word), animated: true)
|
|
}
|
|
|
|
@objc private func startSpelling() {
|
|
Haptics.selection()
|
|
let config = QuizConfig(mode: .spelling, words: [word], isStudyMode: true)
|
|
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
|
|
}
|
|
|
|
private func searchMeaning() {
|
|
Haptics.selection()
|
|
let text = "\(word.meaning) 是什么意思"
|
|
guard let query = text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
let url = URL(string: "https://www.google.com/search?q=\(query)") else { return }
|
|
present(SFSafariViewController(url: url), animated: true)
|
|
}
|
|
|
|
private func makeCaption(_ text: String) -> UILabel {
|
|
let label = UILabel()
|
|
label.text = text
|
|
label.font = .preferredFont(forTextStyle: .caption1)
|
|
label.textColor = .secondaryLabel
|
|
return label
|
|
}
|
|
}
|