引入设计系统,统一 UI 风格,增加拼写/默写结果状态反馈
This commit is contained in:
@@ -8,7 +8,6 @@ enum QuizMode: String, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct QuizSetupRequest: Hashable {
|
struct QuizSetupRequest: Hashable {
|
||||||
let mode: QuizMode
|
|
||||||
let words: [Word]
|
let words: [Word]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
guard let windowScene = scene as? UIWindowScene else { return }
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
let window = UIWindow(windowScene: windowScene)
|
let window = UIWindow(windowScene: windowScene)
|
||||||
|
window.tintColor = Theme.Color.accent
|
||||||
let store = WordStore()
|
let store = WordStore()
|
||||||
store.load()
|
store.load()
|
||||||
let root = CategoryListViewController(store: store)
|
let root = CategoryListViewController(store: store)
|
||||||
window.rootViewController = UINavigationController(rootViewController: root)
|
let navigation = UINavigationController(rootViewController: root)
|
||||||
|
navigation.navigationBar.prefersLargeTitles = true
|
||||||
|
window.rootViewController = navigation
|
||||||
window.makeKeyAndVisible()
|
window.makeKeyAndVisible()
|
||||||
self.window = window
|
self.window = window
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ import AVFoundation
|
|||||||
|
|
||||||
final class SpeechService: NSObject {
|
final class SpeechService: NSObject {
|
||||||
static let shared = SpeechService()
|
static let shared = SpeechService()
|
||||||
|
static let speakingDidChangeNotification = Notification.Name("SpeechServiceSpeakingDidChange")
|
||||||
|
|
||||||
private let synthesizer = AVSpeechSynthesizer()
|
private let synthesizer = AVSpeechSynthesizer()
|
||||||
private(set) var speakingText: String?
|
private(set) var speakingText: String?
|
||||||
var onSpeakingChanged: (() -> Void)?
|
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
synthesizer.delegate = self
|
synthesizer.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isSpeaking(_ text: String) -> Bool {
|
||||||
|
speakingText == text
|
||||||
|
}
|
||||||
|
|
||||||
func speak(_ text: String) {
|
func speak(_ text: String) {
|
||||||
if synthesizer.isSpeaking {
|
if synthesizer.isSpeaking {
|
||||||
synthesizer.stopSpeaking(at: .immediate)
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
@@ -20,19 +24,23 @@ final class SpeechService: NSObject {
|
|||||||
utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
|
utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
|
||||||
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9
|
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9
|
||||||
speakingText = text
|
speakingText = text
|
||||||
onSpeakingChanged?()
|
notifySpeakingChanged()
|
||||||
synthesizer.speak(utterance)
|
synthesizer.speak(utterance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func notifySpeakingChanged() {
|
||||||
|
NotificationCenter.default.post(name: Self.speakingDidChangeNotification, object: self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SpeechService: AVSpeechSynthesizerDelegate {
|
extension SpeechService: AVSpeechSynthesizerDelegate {
|
||||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||||
speakingText = nil
|
speakingText = nil
|
||||||
onSpeakingChanged?()
|
notifySpeakingChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||||
speakingText = nil
|
speakingText = nil
|
||||||
onSpeakingChanged?()
|
notifySpeakingChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum Haptics {
|
||||||
|
static func selection() {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func correct() {
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func wrong() {
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum Theme {
|
||||||
|
enum Color {
|
||||||
|
static let accent = UIColor.systemIndigo
|
||||||
|
static let cardBackground = UIColor.secondarySystemBackground
|
||||||
|
static let correct = UIColor.systemGreen
|
||||||
|
static let wrong = UIColor.systemRed
|
||||||
|
|
||||||
|
private static let categoryPalette: [UIColor] = [
|
||||||
|
.systemIndigo, .systemTeal, .systemOrange, .systemPurple, .systemGreen
|
||||||
|
]
|
||||||
|
|
||||||
|
static func category(at index: Int) -> UIColor {
|
||||||
|
categoryPalette[index % categoryPalette.count]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Metrics {
|
||||||
|
static let cardRadius: CGFloat = 16
|
||||||
|
static let controlRadius: CGFloat = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Font {
|
||||||
|
static func wordHero(size: CGFloat = 44) -> UIFont {
|
||||||
|
rounded(size: size, weight: .bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func rounded(size: CGFloat, weight: UIFont.Weight) -> UIFont {
|
||||||
|
let base = UIFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
guard let descriptor = base.fontDescriptor.withDesign(.rounded) else { return base }
|
||||||
|
return UIFont(descriptor: descriptor, size: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIView {
|
||||||
|
func setPressed(_ pressed: Bool, animated: Bool = true) {
|
||||||
|
let update = {
|
||||||
|
self.transform = pressed ? CGAffineTransform(scaleX: 0.97, y: 0.97) : .identity
|
||||||
|
}
|
||||||
|
guard animated else {
|
||||||
|
update()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.6,
|
||||||
|
initialSpringVelocity: 0, options: [.allowUserInteraction, .beginFromCurrentState],
|
||||||
|
animations: update)
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateEntry(distance: CGFloat = 12, duration: TimeInterval = 0.3, delay: TimeInterval = 0) {
|
||||||
|
alpha = 0
|
||||||
|
transform = CGAffineTransform(translationX: 0, y: distance)
|
||||||
|
UIView.animate(withDuration: duration, delay: delay,
|
||||||
|
options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState]) {
|
||||||
|
self.alpha = 1
|
||||||
|
self.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class CategoryCardCell: UITableViewCell {
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let iconTile = UIView()
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let nameLabel = UILabel()
|
||||||
|
private let countLabel = UILabel()
|
||||||
|
private let chevronView = UIImageView()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
selectionStyle = .none
|
||||||
|
|
||||||
|
cardView.backgroundColor = Theme.Color.cardBackground
|
||||||
|
cardView.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6),
|
||||||
|
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
|
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||||
|
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
|
||||||
|
])
|
||||||
|
|
||||||
|
iconTile.layer.cornerRadius = 12
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
iconTile.addSubview(iconView)
|
||||||
|
iconTile.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
iconTile.widthAnchor.constraint(equalToConstant: 44),
|
||||||
|
iconTile.heightAnchor.constraint(equalToConstant: 44),
|
||||||
|
iconView.centerXAnchor.constraint(equalTo: iconTile.centerXAnchor),
|
||||||
|
iconView.centerYAnchor.constraint(equalTo: iconTile.centerYAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
nameLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
countLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
countLabel.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let textStack = UIStackView(arrangedSubviews: [nameLabel, countLabel])
|
||||||
|
textStack.axis = .vertical
|
||||||
|
textStack.spacing = 2
|
||||||
|
|
||||||
|
chevronView.image = UIImage(systemName: "chevron.right")
|
||||||
|
chevronView.tintColor = .tertiaryLabel
|
||||||
|
chevronView.contentMode = .scaleAspectFit
|
||||||
|
chevronView.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
|
||||||
|
let row = UIStackView(arrangedSubviews: [iconTile, textStack, chevronView])
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .center
|
||||||
|
row.spacing = 12
|
||||||
|
cardView.addSubview(row)
|
||||||
|
row.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
row.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 12),
|
||||||
|
row.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14),
|
||||||
|
row.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14),
|
||||||
|
row.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -12)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
func configure(with category: WordCategory, color: UIColor) {
|
||||||
|
nameLabel.text = category.name
|
||||||
|
countLabel.text = "\(category.words.count) 个单词"
|
||||||
|
iconView.image = UIImage(systemName: category.icon,
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
|
||||||
|
iconView.tintColor = color
|
||||||
|
iconTile.backgroundColor = color.withAlphaComponent(0.12)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||||
|
super.setHighlighted(highlighted, animated: animated)
|
||||||
|
cardView.setPressed(highlighted, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,11 @@ final class CategoryListViewController: UITableViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "单词分类"
|
title = "背单词"
|
||||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .systemBackground
|
||||||
|
tableView.contentInset.top = 8
|
||||||
if let error = store.loadError {
|
if let error = store.loadError {
|
||||||
showError(error)
|
showError(error)
|
||||||
}
|
}
|
||||||
@@ -35,7 +38,6 @@ final class CategoryListViewController: UITableViewController {
|
|||||||
label.leadingAnchor.constraint(greaterThanOrEqualTo: background.leadingAnchor, constant: 24)
|
label.leadingAnchor.constraint(greaterThanOrEqualTo: background.leadingAnchor, constant: 24)
|
||||||
])
|
])
|
||||||
tableView.backgroundView = background
|
tableView.backgroundView = background
|
||||||
tableView.separatorStyle = .none
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
@@ -43,24 +45,15 @@ final class CategoryListViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let category = store.categories[indexPath.row]
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CategoryCardCell
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
cell.configure(with: store.categories[indexPath.row], color: Theme.Color.category(at: indexPath.row))
|
||||||
var content = cell.defaultContentConfiguration()
|
|
||||||
content.text = category.name
|
|
||||||
content.textProperties.font = .preferredFont(forTextStyle: .headline)
|
|
||||||
content.secondaryText = "\(category.words.count) 个单词"
|
|
||||||
content.secondaryTextProperties.color = .secondaryLabel
|
|
||||||
content.image = UIImage(systemName: category.icon)
|
|
||||||
content.imageProperties.tintColor = .tintColor
|
|
||||||
content.imageProperties.preferredSymbolConfiguration = .init(font: .preferredFont(forTextStyle: .title2))
|
|
||||||
cell.contentConfiguration = content
|
|
||||||
cell.accessoryType = .disclosureIndicator
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
Haptics.selection()
|
||||||
let category = store.categories[indexPath.row]
|
let category = store.categories[indexPath.row]
|
||||||
navigationController?.pushViewController(WordListViewController(category: category), animated: true)
|
navigationController?.pushViewController(
|
||||||
|
WordListViewController(category: category, colorIndex: indexPath.row), animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,5 +58,13 @@ final class DictationInputView: UIView, UITextFieldDelegate {
|
|||||||
|
|
||||||
func activate() { textField.becomeFirstResponder() }
|
func activate() { textField.becomeFirstResponder() }
|
||||||
func deactivate() { textField.resignFirstResponder() }
|
func deactivate() { textField.resignFirstResponder() }
|
||||||
func reset() { textField.text = "" }
|
|
||||||
|
func reset() {
|
||||||
|
textField.text = ""
|
||||||
|
bottomLine.backgroundColor = .separator
|
||||||
|
}
|
||||||
|
|
||||||
|
func showResultState(correct: Bool) {
|
||||||
|
bottomLine.backgroundColor = correct ? Theme.Color.correct : Theme.Color.wrong
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,16 @@ final class QuizResultView: UIView {
|
|||||||
private let onExit: () -> Void
|
private let onExit: () -> Void
|
||||||
private let onSelectWord: (Word) -> Void
|
private let onSelectWord: (Word) -> Void
|
||||||
|
|
||||||
|
private let ringView = UIView()
|
||||||
|
private let ringTrackLayer = CAShapeLayer()
|
||||||
|
private let ringProgressLayer = CAShapeLayer()
|
||||||
|
private let scoreLabel = UILabel()
|
||||||
|
private var animationsStarted = false
|
||||||
|
private var displayLink: CADisplayLink?
|
||||||
|
private var countStartTime: CFTimeInterval = 0
|
||||||
|
|
||||||
private var correctCount: Int { total - wrongWords.count }
|
private var correctCount: Int { total - wrongWords.count }
|
||||||
|
private var accuracy: Double { total > 0 ? Double(correctCount) / Double(total) : 0 }
|
||||||
|
|
||||||
private var durationText: String {
|
private var durationText: String {
|
||||||
let seconds = Int(duration)
|
let seconds = Int(duration)
|
||||||
@@ -32,6 +41,56 @@ final class QuizResultView: UIView {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
guard ringView.bounds.width > 0 else { return }
|
||||||
|
ringTrackLayer.path = ringPath()
|
||||||
|
ringProgressLayer.path = ringPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didMoveToWindow() {
|
||||||
|
super.didMoveToWindow()
|
||||||
|
guard window != nil, !animationsStarted else { return }
|
||||||
|
animationsStarted = true
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.startAnimations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ringPath() -> CGPath {
|
||||||
|
let lineWidth = ringTrackLayer.lineWidth
|
||||||
|
let radius = min(ringView.bounds.width, ringView.bounds.height) / 2 - lineWidth / 2
|
||||||
|
let center = CGPoint(x: ringView.bounds.midX, y: ringView.bounds.midY)
|
||||||
|
return UIBezierPath(arcCenter: center, radius: radius,
|
||||||
|
startAngle: -.pi / 2, endAngle: .pi * 1.5, clockwise: true).cgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAnimations() {
|
||||||
|
let animation = CABasicAnimation(keyPath: "strokeEnd")
|
||||||
|
animation.fromValue = 0
|
||||||
|
animation.toValue = accuracy
|
||||||
|
animation.duration = 0.8
|
||||||
|
animation.beginTime = CACurrentMediaTime() + 0.15
|
||||||
|
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
animation.fillMode = .forwards
|
||||||
|
animation.isRemovedOnCompletion = false
|
||||||
|
ringProgressLayer.add(animation, forKey: "progress")
|
||||||
|
|
||||||
|
countStartTime = CACurrentMediaTime() + 0.15
|
||||||
|
let link = CADisplayLink(target: self, selector: #selector(countTick(_:)))
|
||||||
|
link.add(to: .main, forMode: .common)
|
||||||
|
displayLink = link
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func countTick(_ link: CADisplayLink) {
|
||||||
|
let progress = max(0, min(1, (CACurrentMediaTime() - countStartTime) / 0.6))
|
||||||
|
scoreLabel.text = "\(Int((Double(correctCount) * progress).rounded()))/\(total)"
|
||||||
|
if progress >= 1 {
|
||||||
|
link.invalidate()
|
||||||
|
displayLink = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func buildLayout() {
|
private func buildLayout() {
|
||||||
let scrollView = UIScrollView()
|
let scrollView = UIScrollView()
|
||||||
addSubview(scrollView)
|
addSubview(scrollView)
|
||||||
@@ -49,28 +108,14 @@ final class QuizResultView: UIView {
|
|||||||
scrollView.addSubview(stack)
|
scrollView.addSubview(stack)
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 24),
|
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 32),
|
||||||
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||||
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||||
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -24),
|
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -24),
|
||||||
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
||||||
])
|
])
|
||||||
|
|
||||||
let scoreLabel = UILabel()
|
stack.addArrangedSubview(makeScoreSection())
|
||||||
scoreLabel.text = "\(correctCount) / \(total)"
|
|
||||||
scoreLabel.font = .systemFont(ofSize: 44, weight: .bold)
|
|
||||||
scoreLabel.textAlignment = .center
|
|
||||||
|
|
||||||
let durationLabel = UILabel()
|
|
||||||
durationLabel.text = "用时 \(durationText)"
|
|
||||||
durationLabel.font = .preferredFont(forTextStyle: .subheadline)
|
|
||||||
durationLabel.textColor = .secondaryLabel
|
|
||||||
durationLabel.textAlignment = .center
|
|
||||||
|
|
||||||
let scoreStack = UIStackView(arrangedSubviews: [scoreLabel, durationLabel])
|
|
||||||
scoreStack.axis = .vertical
|
|
||||||
scoreStack.spacing = 8
|
|
||||||
stack.addArrangedSubview(scoreStack)
|
|
||||||
|
|
||||||
if !wrongWords.isEmpty {
|
if !wrongWords.isEmpty {
|
||||||
stack.addArrangedSubview(makeWrongWordsSection())
|
stack.addArrangedSubview(makeWrongWordsSection())
|
||||||
@@ -88,19 +133,85 @@ final class QuizResultView: UIView {
|
|||||||
stack.addArrangedSubview(buttonStack)
|
stack.addArrangedSubview(buttonStack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeScoreSection() -> UIView {
|
||||||
|
ringTrackLayer.fillColor = UIColor.clear.cgColor
|
||||||
|
ringTrackLayer.strokeColor = UIColor.tertiarySystemFill.cgColor
|
||||||
|
ringTrackLayer.lineWidth = 10
|
||||||
|
|
||||||
|
ringProgressLayer.fillColor = UIColor.clear.cgColor
|
||||||
|
ringProgressLayer.strokeColor = Theme.Color.accent.cgColor
|
||||||
|
ringProgressLayer.lineWidth = 10
|
||||||
|
ringProgressLayer.lineCap = .round
|
||||||
|
ringProgressLayer.strokeEnd = 0
|
||||||
|
|
||||||
|
ringView.layer.addSublayer(ringTrackLayer)
|
||||||
|
ringView.layer.addSublayer(ringProgressLayer)
|
||||||
|
ringView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
ringView.widthAnchor.constraint(equalToConstant: 132),
|
||||||
|
ringView.heightAnchor.constraint(equalToConstant: 132)
|
||||||
|
])
|
||||||
|
|
||||||
|
scoreLabel.text = "0/\(total)"
|
||||||
|
scoreLabel.font = Theme.Font.rounded(size: 26, weight: .bold)
|
||||||
|
scoreLabel.textAlignment = .center
|
||||||
|
ringView.addSubview(scoreLabel)
|
||||||
|
scoreLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scoreLabel.centerXAnchor.constraint(equalTo: ringView.centerXAnchor),
|
||||||
|
scoreLabel.centerYAnchor.constraint(equalTo: ringView.centerYAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
let captionLabel = UILabel()
|
||||||
|
captionLabel.text = "答对 \(correctCount) 题 · 用时 \(durationText)"
|
||||||
|
captionLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
captionLabel.textColor = .secondaryLabel
|
||||||
|
captionLabel.textAlignment = .center
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [ringView, captionLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 16
|
||||||
|
stack.alignment = .center
|
||||||
|
return stack
|
||||||
|
}
|
||||||
|
|
||||||
private func makeWrongWordsSection() -> UIView {
|
private func makeWrongWordsSection() -> UIView {
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.text = "错题(\(wrongWords.count))"
|
titleLabel.text = "错题(\(wrongWords.count))"
|
||||||
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
|
||||||
let section = UIStackView(arrangedSubviews: [titleLabel])
|
let card = UIView()
|
||||||
|
card.backgroundColor = Theme.Color.cardBackground
|
||||||
|
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||||
|
|
||||||
|
let cardStack = UIStackView()
|
||||||
|
cardStack.axis = .vertical
|
||||||
|
card.addSubview(cardStack)
|
||||||
|
cardStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
cardStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 4),
|
||||||
|
cardStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
|
||||||
|
cardStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
|
||||||
|
cardStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4)
|
||||||
|
])
|
||||||
|
|
||||||
|
for (index, word) in wrongWords.enumerated() {
|
||||||
|
cardStack.addArrangedSubview(makeWrongWordRow(word))
|
||||||
|
if index < wrongWords.count - 1 {
|
||||||
|
cardStack.addArrangedSubview(HairlineView())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let section = UIStackView(arrangedSubviews: [titleLabel, card])
|
||||||
section.axis = .vertical
|
section.axis = .vertical
|
||||||
section.spacing = 8
|
section.spacing = 8
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
for word in wrongWords {
|
private func makeWrongWordRow(_ word: Word) -> UIView {
|
||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
var config = UIButton.Configuration.plain()
|
var config = UIButton.Configuration.plain()
|
||||||
config.contentInsets = .init(top: 8, leading: 12, bottom: 8, trailing: 12)
|
config.contentInsets = .init(top: 10, leading: 2, bottom: 10, trailing: 2)
|
||||||
button.configuration = config
|
button.configuration = config
|
||||||
button.contentHorizontalAlignment = .fill
|
button.contentHorizontalAlignment = .fill
|
||||||
|
|
||||||
@@ -109,6 +220,7 @@ final class QuizResultView: UIView {
|
|||||||
wordLabel.font = .preferredFont(forTextStyle: .headline)
|
wordLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
let meaningLabel = UILabel()
|
let meaningLabel = UILabel()
|
||||||
meaningLabel.text = word.meaning
|
meaningLabel.text = word.meaning
|
||||||
|
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
meaningLabel.textColor = .secondaryLabel
|
meaningLabel.textColor = .secondaryLabel
|
||||||
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
|
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
|
||||||
chevron.tintColor = .tertiaryLabel
|
chevron.tintColor = .tertiaryLabel
|
||||||
@@ -122,21 +234,16 @@ final class QuizResultView: UIView {
|
|||||||
button.addSubview(row)
|
button.addSubview(row)
|
||||||
row.translatesAutoresizingMaskIntoConstraints = false
|
row.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
row.topAnchor.constraint(equalTo: button.topAnchor, constant: 8),
|
row.topAnchor.constraint(equalTo: button.topAnchor, constant: 10),
|
||||||
row.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
|
row.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 2),
|
||||||
row.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -12),
|
row.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -2),
|
||||||
row.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -8)
|
row.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -10)
|
||||||
])
|
])
|
||||||
|
|
||||||
button.addAction(UIAction { [weak self] _ in
|
button.addAction(UIAction { [weak self] _ in
|
||||||
self?.onSelectWord(word)
|
self?.onSelectWord(word)
|
||||||
}, for: .touchUpInside)
|
}, for: .touchUpInside)
|
||||||
section.addArrangedSubview(button)
|
return button
|
||||||
|
|
||||||
let divider = HairlineView()
|
|
||||||
section.addArrangedSubview(divider)
|
|
||||||
}
|
|
||||||
return section
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeButton(title: String, prominent: Bool, action: @escaping () -> Void) -> UIButton {
|
private func makeButton(title: String, prominent: Bool, action: @escaping () -> Void) -> UIButton {
|
||||||
@@ -144,6 +251,9 @@ final class QuizResultView: UIView {
|
|||||||
var config = prominent ? UIButton.Configuration.borderedProminent() : UIButton.Configuration.plain()
|
var config = prominent ? UIButton.Configuration.borderedProminent() : UIButton.Configuration.plain()
|
||||||
config.title = title
|
config.title = title
|
||||||
config.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
|
config.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
|
||||||
|
if prominent {
|
||||||
|
config.background.cornerRadius = Theme.Metrics.controlRadius
|
||||||
|
}
|
||||||
button.configuration = config
|
button.configuration = config
|
||||||
button.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
button.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
||||||
return button
|
return button
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
final class QuizSetupViewController: UITableViewController {
|
final class QuizSetupViewController: UIViewController {
|
||||||
|
private struct ModeItem {
|
||||||
|
let mode: QuizMode
|
||||||
|
let detail: String
|
||||||
|
let icon: String
|
||||||
|
}
|
||||||
|
|
||||||
private let request: QuizSetupRequest
|
private let request: QuizSetupRequest
|
||||||
|
private let items: [ModeItem] = [
|
||||||
|
ModeItem(mode: .enToZh, detail: "看英文选中文释义", icon: "character"),
|
||||||
|
ModeItem(mode: .zhToEn, detail: "看中文选英文单词", icon: "character.bubble"),
|
||||||
|
ModeItem(mode: .spelling, detail: "补全单词空缺字母", icon: "keyboard"),
|
||||||
|
ModeItem(mode: .dictation, detail: "根据释义默写全词", icon: "square.and.pencil")
|
||||||
|
]
|
||||||
|
|
||||||
|
private var selectedIndex: Int?
|
||||||
|
private var cardViews: [UIView] = []
|
||||||
|
private var checkViews: [UIImageView] = []
|
||||||
|
private let countControl = UISegmentedControl()
|
||||||
|
private let startButton = UIButton(type: .system)
|
||||||
|
|
||||||
init(request: QuizSetupRequest) {
|
init(request: QuizSetupRequest) {
|
||||||
self.request = request
|
self.request = request
|
||||||
super.init(style: .plain)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -13,32 +31,176 @@ final class QuizSetupViewController: UITableViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = request.mode.rawValue
|
title = "开始测验"
|
||||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
|
let contentStack = UIStackView()
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = 12
|
||||||
|
|
||||||
|
contentStack.addArrangedSubview(makeCaption("选择模式"))
|
||||||
|
for (index, item) in items.enumerated() {
|
||||||
|
let card = makeModeCard(item: item, index: index)
|
||||||
|
cardViews.append(card)
|
||||||
|
contentStack.addArrangedSubview(card)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 }
|
contentStack.setCustomSpacing(24, after: cardViews.last!)
|
||||||
|
contentStack.addArrangedSubview(makeCaption("题量"))
|
||||||
|
setupCountControl()
|
||||||
|
contentStack.addArrangedSubview(countControl)
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
let scrollView = UIScrollView()
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
scrollView.addSubview(contentStack)
|
||||||
var content = cell.defaultContentConfiguration()
|
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
content.textProperties.font = .preferredFont(forTextStyle: .headline)
|
NSLayoutConstraint.activate([
|
||||||
content.secondaryTextProperties.color = .secondaryLabel
|
contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 8),
|
||||||
if indexPath.row == 0 {
|
contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||||
content.text = "全部随机测试"
|
contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||||
content.secondaryText = "\(request.words.count) 题"
|
contentStack.bottomAnchor.constraint(lessThanOrEqualTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||||
|
contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
||||||
|
])
|
||||||
|
|
||||||
|
var buttonConfig = UIButton.Configuration.borderedProminent()
|
||||||
|
buttonConfig.title = "开始"
|
||||||
|
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
|
||||||
|
buttonConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
||||||
|
buttonConfig.titleTextAttributesTransformer = .init { incoming in
|
||||||
|
var outgoing = incoming
|
||||||
|
outgoing.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
startButton.configuration = buttonConfig
|
||||||
|
startButton.isEnabled = false
|
||||||
|
startButton.addAction(UIAction { [weak self] _ in self?.startQuiz() }, for: .touchUpInside)
|
||||||
|
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
view.addSubview(startButton)
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
startButton.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: startButton.topAnchor, constant: -12),
|
||||||
|
|
||||||
|
startButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||||
|
startButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||||
|
startButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12)
|
||||||
|
])
|
||||||
|
|
||||||
|
updateCards(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCaption(_ text: String) -> UILabel {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = text
|
||||||
|
label.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
label.textColor = .secondaryLabel
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeModeCard(item: ModeItem, index: Int) -> UIView {
|
||||||
|
let card = UIView()
|
||||||
|
card.tag = index
|
||||||
|
card.backgroundColor = Theme.Color.cardBackground
|
||||||
|
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||||
|
card.layer.borderWidth = 1.5
|
||||||
|
card.layer.borderColor = UIColor.clear.cgColor
|
||||||
|
|
||||||
|
let tile = UIView()
|
||||||
|
tile.backgroundColor = Theme.Color.accent.withAlphaComponent(0.12)
|
||||||
|
tile.layer.cornerRadius = 10
|
||||||
|
let icon = UIImageView(image: UIImage(systemName: item.icon,
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)))
|
||||||
|
icon.tintColor = Theme.Color.accent
|
||||||
|
tile.addSubview(icon)
|
||||||
|
tile.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
tile.widthAnchor.constraint(equalToConstant: 38),
|
||||||
|
tile.heightAnchor.constraint(equalToConstant: 38),
|
||||||
|
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
||||||
|
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = item.mode.rawValue
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
let detailLabel = UILabel()
|
||||||
|
detailLabel.text = item.detail
|
||||||
|
detailLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
detailLabel.textColor = .secondaryLabel
|
||||||
|
let textStack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
|
||||||
|
textStack.axis = .vertical
|
||||||
|
textStack.spacing = 2
|
||||||
|
|
||||||
|
let check = UIImageView(image: UIImage(systemName: "circle",
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)))
|
||||||
|
check.tintColor = .tertiaryLabel
|
||||||
|
check.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
checkViews.append(check)
|
||||||
|
|
||||||
|
let row = UIStackView(arrangedSubviews: [tile, textStack, check])
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .center
|
||||||
|
row.spacing = 12
|
||||||
|
row.isUserInteractionEnabled = false
|
||||||
|
card.addSubview(row)
|
||||||
|
row.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
row.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
|
||||||
|
row.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
|
||||||
|
row.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
|
||||||
|
row.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
|
||||||
|
])
|
||||||
|
|
||||||
|
card.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cardTapped(_:))))
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupCountControl() {
|
||||||
|
countControl.insertSegment(withTitle: "全部 \(request.words.count) 题", at: 0, animated: false)
|
||||||
|
if request.words.count > 10 {
|
||||||
|
countControl.insertSegment(withTitle: "随机 10 题", at: 1, animated: false)
|
||||||
|
}
|
||||||
|
countControl.selectedSegmentIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cardTapped(_ gesture: UITapGestureRecognizer) {
|
||||||
|
guard let card = gesture.view, selectedIndex != card.tag else { return }
|
||||||
|
selectedIndex = card.tag
|
||||||
|
Haptics.selection()
|
||||||
|
updateCards(animated: true)
|
||||||
|
startButton.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCards(animated: Bool) {
|
||||||
|
for (index, card) in cardViews.enumerated() {
|
||||||
|
let selected = index == selectedIndex
|
||||||
|
let apply = {
|
||||||
|
card.backgroundColor = selected ? Theme.Color.accent.withAlphaComponent(0.12) : Theme.Color.cardBackground
|
||||||
|
card.layer.borderColor = selected ? Theme.Color.accent.cgColor : UIColor.clear.cgColor
|
||||||
|
self.checkViews[index].image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle",
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
|
||||||
|
self.checkViews[index].tintColor = selected ? Theme.Color.accent : .tertiaryLabel
|
||||||
|
}
|
||||||
|
if animated {
|
||||||
|
UIView.animate(withDuration: 0.2, animations: apply)
|
||||||
} else {
|
} else {
|
||||||
content.text = "随机十个"
|
apply()
|
||||||
content.secondaryText = "从词库中随机抽取 10 题"
|
}
|
||||||
}
|
}
|
||||||
cell.contentConfiguration = content
|
|
||||||
cell.accessoryType = .disclosureIndicator
|
|
||||||
return cell
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
private func startQuiz() {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
guard let selectedIndex else { return }
|
||||||
let words = indexPath.row == 0 ? request.words : Array(request.words.shuffled().prefix(10))
|
Haptics.selection()
|
||||||
navigationController?.pushViewController(QuizViewController(config: QuizConfig(mode: request.mode, words: words)), animated: true)
|
let words = countControl.selectedSegmentIndex == 0
|
||||||
|
? request.words
|
||||||
|
: Array(request.words.shuffled().prefix(10))
|
||||||
|
let config = QuizConfig(mode: items[selectedIndex].mode, words: words)
|
||||||
|
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ final class QuizViewController: UIViewController {
|
|||||||
private let resultPhoneticLabel = UILabel()
|
private let resultPhoneticLabel = UILabel()
|
||||||
private let nextButton = UIButton(type: .system)
|
private let nextButton = UIButton(type: .system)
|
||||||
private var optionButtons: [UIButton] = []
|
private var optionButtons: [UIButton] = []
|
||||||
|
private var optionIcons: [UIImageView] = []
|
||||||
|
|
||||||
|
private let promptStack = UIStackView()
|
||||||
|
private let inputStack = UIStackView()
|
||||||
|
private let resultStack = UIStackView()
|
||||||
|
|
||||||
private var current: QuizQuestion? {
|
private var current: QuizQuestion? {
|
||||||
questions.indices.contains(currentIndex) ? questions[currentIndex] : nil
|
questions.indices.contains(currentIndex) ? questions[currentIndex] : nil
|
||||||
@@ -41,6 +46,7 @@ final class QuizViewController: UIViewController {
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
title = config.mode.rawValue
|
title = config.mode.rawValue
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
buildLayout()
|
buildLayout()
|
||||||
showQuestion()
|
showQuestion()
|
||||||
}
|
}
|
||||||
@@ -57,11 +63,17 @@ final class QuizViewController: UIViewController {
|
|||||||
// MARK: - Layout
|
// MARK: - Layout
|
||||||
|
|
||||||
private func buildLayout() {
|
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.font = .preferredFont(forTextStyle: .caption1)
|
||||||
countLabel.textColor = .secondaryLabel
|
countLabel.textColor = .secondaryLabel
|
||||||
countLabel.textAlignment = .center
|
countLabel.textAlignment = .center
|
||||||
|
|
||||||
promptLabel.font = .systemFont(ofSize: config.mode == .enToZh ? 44 : 34, weight: .bold)
|
promptLabel.font = config.mode == .enToZh
|
||||||
|
? Theme.Font.wordHero(size: 40)
|
||||||
|
: .systemFont(ofSize: 34, weight: .bold)
|
||||||
promptLabel.textAlignment = .center
|
promptLabel.textAlignment = .center
|
||||||
promptLabel.numberOfLines = 0
|
promptLabel.numberOfLines = 0
|
||||||
|
|
||||||
@@ -69,7 +81,10 @@ final class QuizViewController: UIViewController {
|
|||||||
phoneticLabel.textColor = .secondaryLabel
|
phoneticLabel.textColor = .secondaryLabel
|
||||||
phoneticLabel.textAlignment = .center
|
phoneticLabel.textAlignment = .center
|
||||||
|
|
||||||
speakerButton.setImage(UIImage(systemName: "speaker.wave.2.fill"), for: .normal)
|
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
|
speakerButton.addAction(UIAction { [weak self] _ in
|
||||||
guard let self, let question = current else { return }
|
guard let self, let question = current else { return }
|
||||||
speech.speak(question.prompt)
|
speech.speak(question.prompt)
|
||||||
@@ -91,6 +106,7 @@ final class QuizViewController: UIViewController {
|
|||||||
|
|
||||||
var nextConfig = UIButton.Configuration.borderedProminent()
|
var nextConfig = UIButton.Configuration.borderedProminent()
|
||||||
nextConfig.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
|
nextConfig.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
|
||||||
|
nextConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
||||||
nextButton.configuration = nextConfig
|
nextButton.configuration = nextConfig
|
||||||
nextButton.addAction(UIAction { [weak self] _ in self?.primaryButtonTapped() }, for: .touchUpInside)
|
nextButton.addAction(UIAction { [weak self] _ in self?.primaryButtonTapped() }, for: .touchUpInside)
|
||||||
|
|
||||||
@@ -105,18 +121,18 @@ final class QuizViewController: UIViewController {
|
|||||||
headerStack.axis = .vertical
|
headerStack.axis = .vertical
|
||||||
headerStack.spacing = 8
|
headerStack.spacing = 8
|
||||||
|
|
||||||
let promptStack = UIStackView(arrangedSubviews: [promptLabel, phoneticLabel, speakerButton])
|
|
||||||
promptStack.axis = .vertical
|
promptStack.axis = .vertical
|
||||||
promptStack.spacing = 12
|
promptStack.spacing = 12
|
||||||
promptStack.alignment = .center
|
promptStack.alignment = .center
|
||||||
|
[promptLabel, phoneticLabel, speakerButton].forEach { promptStack.addArrangedSubview($0) }
|
||||||
|
|
||||||
let inputStack = UIStackView(arrangedSubviews: [optionsStack, spellingView, dictationView, spellingHintLabel])
|
|
||||||
inputStack.axis = .vertical
|
inputStack.axis = .vertical
|
||||||
inputStack.spacing = 16
|
inputStack.spacing = 16
|
||||||
|
[optionsStack, spellingView, dictationView, spellingHintLabel].forEach { inputStack.addArrangedSubview($0) }
|
||||||
|
|
||||||
let resultStack = UIStackView(arrangedSubviews: [resultLabel, resultPhoneticLabel, nextButton])
|
|
||||||
resultStack.axis = .vertical
|
resultStack.axis = .vertical
|
||||||
resultStack.spacing = 12
|
resultStack.spacing = 12
|
||||||
|
[resultLabel, resultPhoneticLabel, nextButton].forEach { resultStack.addArrangedSubview($0) }
|
||||||
|
|
||||||
let mainStack = UIStackView(arrangedSubviews: [headerStack, promptStack, inputStack, resultStack, UIView()])
|
let mainStack = UIStackView(arrangedSubviews: [headerStack, promptStack, inputStack, resultStack, UIView()])
|
||||||
mainStack.axis = .vertical
|
mainStack.axis = .vertical
|
||||||
@@ -137,8 +153,8 @@ final class QuizViewController: UIViewController {
|
|||||||
guard let question = current else { return }
|
guard let question = current else { return }
|
||||||
selectedOption = nil
|
selectedOption = nil
|
||||||
|
|
||||||
progressView.progress = Float(currentIndex) / Float(questions.count)
|
progressView.setProgress(Float(currentIndex) / Float(questions.count), animated: true)
|
||||||
countLabel.text = "第 \(currentIndex + 1) / \(questions.count) 题"
|
countLabel.text = "\(currentIndex + 1) / \(questions.count)"
|
||||||
promptLabel.text = question.prompt
|
promptLabel.text = question.prompt
|
||||||
phoneticLabel.isHidden = config.mode != .enToZh
|
phoneticLabel.isHidden = config.mode != .enToZh
|
||||||
speakerButton.isHidden = config.mode != .enToZh
|
speakerButton.isHidden = config.mode != .enToZh
|
||||||
@@ -188,66 +204,119 @@ final class QuizViewController: UIViewController {
|
|||||||
|
|
||||||
private func buildOptionButtons(for question: QuizQuestion) {
|
private func buildOptionButtons(for question: QuizQuestion) {
|
||||||
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
optionButtons = question.options.map { option in
|
optionButtons = []
|
||||||
|
optionIcons = []
|
||||||
|
for option in question.options {
|
||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
var config = UIButton.Configuration.bordered()
|
var buttonConfig = UIButton.Configuration.bordered()
|
||||||
config.title = option
|
buttonConfig.title = option
|
||||||
config.titleTextAttributesTransformer = .init { incoming in
|
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
|
var outgoing = incoming
|
||||||
outgoing.font = .preferredFont(forTextStyle: .headline)
|
outgoing.font = .preferredFont(forTextStyle: .headline)
|
||||||
return outgoing
|
return outgoing
|
||||||
}
|
}
|
||||||
if self.config.mode == .zhToEn, let w = self.config.words.first(where: { $0.word == option }) {
|
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 44)
|
||||||
config.subtitle = w.phonetic
|
button.configuration = buttonConfig
|
||||||
}
|
button.contentHorizontalAlignment = .leading
|
||||||
config.contentInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16)
|
|
||||||
button.configuration = config
|
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
|
button.addAction(UIAction { [weak self] _ in
|
||||||
self?.selectOption(option, for: question)
|
self?.selectOption(option, for: question)
|
||||||
}, for: .touchUpInside)
|
}, for: .touchUpInside)
|
||||||
optionsStack.addArrangedSubview(button)
|
optionsStack.addArrangedSubview(button)
|
||||||
return button
|
optionButtons.append(button)
|
||||||
|
optionIcons.append(icon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectOption(_ option: String, for question: QuizQuestion) {
|
private func selectOption(_ option: String, for question: QuizQuestion) {
|
||||||
guard selectedOption == nil else { return }
|
guard selectedOption == nil else { return }
|
||||||
selectedOption = option
|
selectedOption = option
|
||||||
if option != question.answer {
|
let correct = option == question.answer
|
||||||
|
if !correct {
|
||||||
wrongWords.append(question.word)
|
wrongWords.append(question.word)
|
||||||
}
|
}
|
||||||
|
correct ? Haptics.correct() : Haptics.wrong()
|
||||||
|
|
||||||
for (index, button) in optionButtons.enumerated() {
|
for (index, button) in optionButtons.enumerated() {
|
||||||
let value = question.options[index]
|
let value = question.options[index]
|
||||||
button.isEnabled = false
|
button.isUserInteractionEnabled = false
|
||||||
|
var buttonConfig = button.configuration
|
||||||
if value == question.answer {
|
if value == question.answer {
|
||||||
button.configuration?.baseForegroundColor = .systemGreen
|
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 {
|
} else if value == option {
|
||||||
button.configuration?.baseForegroundColor = .systemRed
|
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 {
|
} else {
|
||||||
button.configuration?.baseForegroundColor = .systemGray
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showResult(option == question.answer, for: question)
|
showResult(correct, for: question)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
if typed != question.answer {
|
let correct = typed == question.answer
|
||||||
|
if !correct {
|
||||||
wrongWords.append(question.word)
|
wrongWords.append(question.word)
|
||||||
}
|
}
|
||||||
|
correct ? Haptics.correct() : Haptics.wrong()
|
||||||
spellingView.deactivate()
|
spellingView.deactivate()
|
||||||
showResult(typed == question.answer, for: question)
|
spellingView.showResultState(correct: correct)
|
||||||
|
showResult(correct, for: question)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitDictation(_ typed: String, for question: QuizQuestion) {
|
private func submitDictation(_ typed: String, for question: QuizQuestion) {
|
||||||
guard selectedOption == nil else { return }
|
guard selectedOption == nil else { return }
|
||||||
selectedOption = typed
|
selectedOption = typed
|
||||||
if typed != question.answer {
|
let correct = typed == question.answer
|
||||||
|
if !correct {
|
||||||
wrongWords.append(question.word)
|
wrongWords.append(question.word)
|
||||||
}
|
}
|
||||||
|
correct ? Haptics.correct() : Haptics.wrong()
|
||||||
dictationView.deactivate()
|
dictationView.deactivate()
|
||||||
showResult(typed == question.answer, for: question)
|
dictationView.showResultState(correct: correct)
|
||||||
|
showResult(correct, for: question)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showResult(_ correct: Bool, for question: QuizQuestion) {
|
private func showResult(_ correct: Bool, for question: QuizQuestion) {
|
||||||
@@ -258,7 +327,7 @@ final class QuizViewController: UIViewController {
|
|||||||
} else {
|
} else {
|
||||||
resultLabel.text = correct ? "回答正确" : "正确答案:\(question.answer)"
|
resultLabel.text = correct ? "回答正确" : "正确答案:\(question.answer)"
|
||||||
}
|
}
|
||||||
resultLabel.textColor = correct ? .systemGreen : .systemRed
|
resultLabel.textColor = correct ? Theme.Color.correct : Theme.Color.wrong
|
||||||
resultLabel.isHidden = false
|
resultLabel.isHidden = false
|
||||||
|
|
||||||
nextButton.configuration?.title = currentIndex + 1 < questions.count ? "下一题" : "查看成绩"
|
nextButton.configuration?.title = currentIndex + 1 < questions.count ? "下一题" : "查看成绩"
|
||||||
@@ -268,7 +337,7 @@ final class QuizViewController: UIViewController {
|
|||||||
|
|
||||||
@objc private func speakCurrentWord() {
|
@objc private func speakCurrentWord() {
|
||||||
guard let question = current else { return }
|
guard let question = current else { return }
|
||||||
speech.speak(question.answer)
|
speech.speak(question.word.word)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func primaryButtonTapped() {
|
private func primaryButtonTapped() {
|
||||||
@@ -284,15 +353,34 @@ final class QuizViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func next() {
|
private func next() {
|
||||||
|
nextButton.isEnabled = false
|
||||||
if currentIndex + 1 < questions.count {
|
if currentIndex + 1 < questions.count {
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
showQuestion()
|
animateQuestionSwap()
|
||||||
} else {
|
} else {
|
||||||
showQuizResult()
|
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() {
|
private func showQuizResult() {
|
||||||
|
progressView.setProgress(1, animated: true)
|
||||||
let resultView = QuizResultView(
|
let resultView = QuizResultView(
|
||||||
total: questions.count,
|
total: questions.count,
|
||||||
wrongWords: wrongWords,
|
wrongWords: wrongWords,
|
||||||
@@ -303,10 +391,16 @@ final class QuizViewController: UIViewController {
|
|||||||
self?.navigationController?.pushViewController(WordDetailViewController(word: word), animated: true)
|
self?.navigationController?.pushViewController(WordDetailViewController(word: word), animated: true)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
resultView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
resultView.frame = view.bounds
|
resultView.frame = view.bounds
|
||||||
resultView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
resultView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
resultView.alpha = 0
|
||||||
|
resultView.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
||||||
view.addSubview(resultView)
|
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() {
|
private func restart() {
|
||||||
|
|||||||
@@ -76,6 +76,26 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
refreshBoxes()
|
refreshBoxes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func showResultState(correct: Bool) {
|
||||||
|
let color = correct ? Theme.Color.correct : Theme.Color.wrong
|
||||||
|
for position in blankPositions {
|
||||||
|
boxViews[position].layer.borderColor = color.cgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func popBox(atBlank blankIndex: Int) {
|
||||||
|
guard blankPositions.indices.contains(blankIndex) else { return }
|
||||||
|
let box = boxViews[blankPositions[blankIndex]]
|
||||||
|
UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.5,
|
||||||
|
initialSpringVelocity: 0, options: .allowUserInteraction) {
|
||||||
|
box.transform = CGAffineTransform(scaleX: 1.12, y: 1.12)
|
||||||
|
} completion: { _ in
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
box.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func reactivate() {
|
@objc private func reactivate() {
|
||||||
activate()
|
activate()
|
||||||
}
|
}
|
||||||
@@ -95,6 +115,7 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
|
|||||||
filledBlanks[cursorIndex] = String(char).lowercased()
|
filledBlanks[cursorIndex] = String(char).lowercased()
|
||||||
cursorIndex += 1
|
cursorIndex += 1
|
||||||
refreshBoxes()
|
refreshBoxes()
|
||||||
|
popBox(atBlank: cursorIndex - 1)
|
||||||
onChange?()
|
onChange?()
|
||||||
}
|
}
|
||||||
textField.text = nil
|
textField.text = nil
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class WordCardCell: UITableViewCell {
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let wordLabel = UILabel()
|
||||||
|
private let phoneticLabel = UILabel()
|
||||||
|
private let meaningLabel = UILabel()
|
||||||
|
private let speakerButton = UIButton(type: .system)
|
||||||
|
private var word: Word?
|
||||||
|
|
||||||
|
var onSpeak: ((Word) -> Void)?
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
selectionStyle = .none
|
||||||
|
|
||||||
|
cardView.backgroundColor = Theme.Color.cardBackground
|
||||||
|
cardView.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5),
|
||||||
|
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
|
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||||
|
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5)
|
||||||
|
])
|
||||||
|
|
||||||
|
wordLabel.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
phoneticLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||||
|
phoneticLabel.textColor = .tertiaryLabel
|
||||||
|
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
meaningLabel.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let titleRow = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, UIView()])
|
||||||
|
titleRow.axis = .horizontal
|
||||||
|
titleRow.alignment = .firstBaseline
|
||||||
|
titleRow.spacing = 6
|
||||||
|
|
||||||
|
let textStack = UIStackView(arrangedSubviews: [titleRow, meaningLabel])
|
||||||
|
textStack.axis = .vertical
|
||||||
|
textStack.spacing = 2
|
||||||
|
|
||||||
|
speakerButton.tintColor = Theme.Color.accent
|
||||||
|
speakerButton.addAction(UIAction { [weak self] _ in
|
||||||
|
guard let self, let word else { return }
|
||||||
|
onSpeak?(word)
|
||||||
|
}, for: .touchUpInside)
|
||||||
|
speakerButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
|
||||||
|
let row = UIStackView(arrangedSubviews: [textStack, speakerButton])
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .center
|
||||||
|
row.spacing = 8
|
||||||
|
cardView.addSubview(row)
|
||||||
|
row.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
row.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 10),
|
||||||
|
row.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14),
|
||||||
|
row.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -8),
|
||||||
|
row.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10),
|
||||||
|
speakerButton.widthAnchor.constraint(equalToConstant: 44),
|
||||||
|
speakerButton.heightAnchor.constraint(equalToConstant: 44)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
func configure(with word: Word) {
|
||||||
|
self.word = word
|
||||||
|
wordLabel.text = word.word
|
||||||
|
phoneticLabel.text = word.phonetic
|
||||||
|
meaningLabel.text = word.meaning
|
||||||
|
updateSpeakerState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSpeakerState() {
|
||||||
|
guard let word else { return }
|
||||||
|
let speaking = SpeechService.shared.isSpeaking(word.word)
|
||||||
|
let symbol = speaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill"
|
||||||
|
speakerButton.setImage(UIImage(systemName: symbol,
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)),
|
||||||
|
for: .normal)
|
||||||
|
speakerButton.isEnabled = !speaking
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||||
|
super.setHighlighted(highlighted, animated: animated)
|
||||||
|
cardView.setPressed(highlighted, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ final class WordDetailViewController: UIViewController {
|
|||||||
private let speech = SpeechService.shared
|
private let speech = SpeechService.shared
|
||||||
|
|
||||||
private let speakButton = UIButton(type: .system)
|
private let speakButton = UIButton(type: .system)
|
||||||
private var isSpeaking: Bool { speech.speakingText == word.word }
|
private let exampleSpeakButton = UIButton(type: .system)
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
private var speechObserver: NSObjectProtocol?
|
||||||
|
private var didAnimateEntry = false
|
||||||
|
|
||||||
init(word: Word) {
|
init(word: Word) {
|
||||||
self.word = word
|
self.word = word
|
||||||
@@ -15,28 +18,46 @@ final class WordDetailViewController: UIViewController {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let speechObserver {
|
||||||
|
NotificationCenter.default.removeObserver(speechObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
title = word.word
|
title = word.word
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
buildLayout()
|
buildLayout()
|
||||||
updateSpeakButton()
|
updateSpeakState()
|
||||||
}
|
speechObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
) { [weak self] _ in
|
||||||
super.viewWillAppear(animated)
|
self?.updateSpeakState()
|
||||||
speech.onSpeakingChanged = { [weak self] in
|
|
||||||
self?.updateSpeakButton()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateSpeakButton() {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
var config = speakButton.configuration
|
super.viewDidAppear(animated)
|
||||||
config?.title = isSpeaking ? "朗读中" : "发音"
|
if !didAnimateEntry {
|
||||||
config?.image = UIImage(systemName: isSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill")
|
didAnimateEntry = true
|
||||||
speakButton.configuration = config
|
contentStack.animateEntry(distance: 16, duration: 0.35)
|
||||||
speakButton.isEnabled = !isSpeaking
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
private func buildLayout() {
|
||||||
@@ -50,55 +71,67 @@ final class WordDetailViewController: UIViewController {
|
|||||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
])
|
])
|
||||||
|
|
||||||
let stack = UIStackView()
|
contentStack.axis = .vertical
|
||||||
stack.axis = .vertical
|
contentStack.spacing = 32
|
||||||
stack.spacing = 32
|
scrollView.addSubview(contentStack)
|
||||||
scrollView.addSubview(stack)
|
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
|
contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 24),
|
||||||
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||||
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||||
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||||
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
|
||||||
])
|
])
|
||||||
|
|
||||||
stack.addArrangedSubview(makeHeader())
|
contentStack.addArrangedSubview(makeHeader())
|
||||||
stack.addArrangedSubview(makeCard())
|
contentStack.addArrangedSubview(makeCard())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeHeader() -> UIView {
|
private func makeHeader() -> UIView {
|
||||||
let wordLabel = UILabel()
|
let wordLabel = UILabel()
|
||||||
wordLabel.text = word.word
|
wordLabel.text = word.word
|
||||||
wordLabel.font = .systemFont(ofSize: 48, weight: .bold)
|
wordLabel.font = Theme.Font.wordHero()
|
||||||
wordLabel.textAlignment = .center
|
wordLabel.textAlignment = .center
|
||||||
|
|
||||||
|
let phoneticIcon = UIImageView(image: UIImage(
|
||||||
|
systemName: "speaker.wave.2",
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .medium)))
|
||||||
|
phoneticIcon.tintColor = .secondaryLabel
|
||||||
let phoneticLabel = UILabel()
|
let phoneticLabel = UILabel()
|
||||||
phoneticLabel.text = word.phonetic
|
phoneticLabel.text = word.phonetic
|
||||||
phoneticLabel.font = .preferredFont(forTextStyle: .title3)
|
phoneticLabel.font = .preferredFont(forTextStyle: .title3)
|
||||||
phoneticLabel.textColor = .secondaryLabel
|
phoneticLabel.textColor = .secondaryLabel
|
||||||
phoneticLabel.textAlignment = .center
|
let phoneticRow = UIStackView(arrangedSubviews: [phoneticIcon, phoneticLabel])
|
||||||
|
phoneticRow.axis = .horizontal
|
||||||
|
phoneticRow.alignment = .center
|
||||||
|
phoneticRow.spacing = 6
|
||||||
|
|
||||||
var config = UIButton.Configuration.borderedProminent()
|
var buttonConfig = UIButton.Configuration.filled()
|
||||||
config.imagePadding = 6
|
buttonConfig.baseBackgroundColor = Theme.Color.accent
|
||||||
config.contentInsets = .init(top: 10, leading: 24, bottom: 10, trailing: 24)
|
buttonConfig.baseForegroundColor = .white
|
||||||
speakButton.configuration = config
|
buttonConfig.cornerStyle = .capsule
|
||||||
|
speakButton.configuration = buttonConfig
|
||||||
speakButton.addAction(UIAction { [weak self] _ in
|
speakButton.addAction(UIAction { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
speech.speak(word.word)
|
speech.speak(word.word)
|
||||||
}, for: .touchUpInside)
|
}, for: .touchUpInside)
|
||||||
|
speakButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
speakButton.widthAnchor.constraint(equalToConstant: 56),
|
||||||
|
speakButton.heightAnchor.constraint(equalToConstant: 56)
|
||||||
|
])
|
||||||
|
|
||||||
let stack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, speakButton])
|
let stack = UIStackView(arrangedSubviews: [wordLabel, phoneticRow, speakButton])
|
||||||
stack.axis = .vertical
|
stack.axis = .vertical
|
||||||
stack.spacing = 12
|
stack.spacing = 16
|
||||||
stack.alignment = .center
|
stack.alignment = .center
|
||||||
return stack
|
return stack
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeCard() -> UIView {
|
private func makeCard() -> UIView {
|
||||||
let card = UIView()
|
let card = UIView()
|
||||||
card.backgroundColor = .quaternarySystemFill.withAlphaComponent(0.5)
|
card.backgroundColor = Theme.Color.cardBackground
|
||||||
card.layer.cornerRadius = 16
|
card.layer.cornerRadius = Theme.Metrics.cardRadius
|
||||||
|
|
||||||
let meaningTitle = makeCaption("释义")
|
let meaningTitle = makeCaption("释义")
|
||||||
let meaningLabel = UILabel()
|
let meaningLabel = UILabel()
|
||||||
@@ -118,8 +151,9 @@ final class WordDetailViewController: UIViewController {
|
|||||||
exampleMeaningLabel.textColor = .secondaryLabel
|
exampleMeaningLabel.textColor = .secondaryLabel
|
||||||
exampleMeaningLabel.numberOfLines = 0
|
exampleMeaningLabel.numberOfLines = 0
|
||||||
|
|
||||||
let exampleSpeakButton = UIButton(type: .system)
|
var ghostConfig = UIButton.Configuration.plain()
|
||||||
exampleSpeakButton.setImage(UIImage(systemName: "speaker.wave.2"), for: .normal)
|
ghostConfig.baseForegroundColor = Theme.Color.accent
|
||||||
|
exampleSpeakButton.configuration = ghostConfig
|
||||||
exampleSpeakButton.addAction(UIAction { [weak self] _ in
|
exampleSpeakButton.addAction(UIAction { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
speech.speak(word.example)
|
speech.speak(word.example)
|
||||||
|
|||||||
@@ -1,60 +1,153 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
final class WordListViewController: UITableViewController {
|
final class WordListViewController: UIViewController {
|
||||||
private let category: WordCategory
|
private let category: WordCategory
|
||||||
|
private let colorIndex: Int
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let ctaBackground = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||||
|
private let quizButton = UIButton(type: .system)
|
||||||
|
private var speechObserver: NSObjectProtocol?
|
||||||
|
|
||||||
init(category: WordCategory) {
|
init(category: WordCategory, colorIndex: Int) {
|
||||||
self.category = category
|
self.category = category
|
||||||
super.init(style: .plain)
|
self.colorIndex = colorIndex
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let speechObserver {
|
||||||
|
NotificationCenter.default.removeObserver(speechObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = category.name
|
title = category.name
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
let quizMenu = UIMenu(title: "", children: [
|
tableView.dataSource = self
|
||||||
UIAction(title: QuizMode.enToZh.rawValue) { [weak self] _ in self?.startQuiz(.enToZh) },
|
tableView.delegate = self
|
||||||
UIAction(title: QuizMode.zhToEn.rawValue) { [weak self] _ in self?.startQuiz(.zhToEn) },
|
tableView.register(WordCardCell.self, forCellReuseIdentifier: "cell")
|
||||||
UIAction(title: QuizMode.spelling.rawValue) { [weak self] _ in self?.startQuiz(.spelling) },
|
tableView.separatorStyle = .none
|
||||||
UIAction(title: QuizMode.dictation.rawValue) { [weak self] _ in self?.startQuiz(.dictation) }
|
tableView.backgroundColor = .systemBackground
|
||||||
|
tableView.tableHeaderView = makeHeader()
|
||||||
|
tableView.contentInset.bottom = 92
|
||||||
|
tableView.verticalScrollIndicatorInsets.bottom = 92
|
||||||
|
|
||||||
|
var buttonConfig = UIButton.Configuration.borderedProminent()
|
||||||
|
buttonConfig.title = "开始测验"
|
||||||
|
buttonConfig.image = UIImage(systemName: "pencil.and.list.clipboard")
|
||||||
|
buttonConfig.imagePadding = 6
|
||||||
|
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
|
||||||
|
buttonConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
||||||
|
buttonConfig.titleTextAttributesTransformer = .init { incoming in
|
||||||
|
var outgoing = incoming
|
||||||
|
outgoing.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
quizButton.configuration = buttonConfig
|
||||||
|
quizButton.addAction(UIAction { [weak self] _ in self?.startQuiz() }, for: .touchUpInside)
|
||||||
|
|
||||||
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(ctaBackground)
|
||||||
|
ctaBackground.contentView.addSubview(quizButton)
|
||||||
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
ctaBackground.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
quizButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
|
ctaBackground.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
ctaBackground.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
ctaBackground.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
|
quizButton.topAnchor.constraint(equalTo: ctaBackground.contentView.topAnchor, constant: 12),
|
||||||
|
quizButton.leadingAnchor.constraint(equalTo: ctaBackground.contentView.leadingAnchor, constant: 16),
|
||||||
|
quizButton.trailingAnchor.constraint(equalTo: ctaBackground.contentView.trailingAnchor, constant: -16),
|
||||||
|
quizButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12)
|
||||||
])
|
])
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "测验", menu: quizMenu)
|
|
||||||
|
speechObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.updateVisibleSpeakers()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startQuiz(_ mode: QuizMode) {
|
private func makeHeader() -> UIView {
|
||||||
if category.words.count <= 10 {
|
let color = Theme.Color.category(at: colorIndex)
|
||||||
let config = QuizConfig(mode: mode, words: category.words)
|
let header = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 56))
|
||||||
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
|
|
||||||
} else {
|
let tile = UIView()
|
||||||
let request = QuizSetupRequest(mode: mode, words: category.words)
|
tile.backgroundColor = color.withAlphaComponent(0.12)
|
||||||
|
tile.layer.cornerRadius = 10
|
||||||
|
let icon = UIImageView(image: UIImage(systemName: category.icon,
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)))
|
||||||
|
icon.tintColor = color
|
||||||
|
icon.contentMode = .scaleAspectFit
|
||||||
|
tile.addSubview(icon)
|
||||||
|
header.addSubview(tile)
|
||||||
|
tile.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "共 \(category.words.count) 个单词"
|
||||||
|
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
label.textColor = .secondaryLabel
|
||||||
|
header.addSubview(label)
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
tile.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 16),
|
||||||
|
tile.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
||||||
|
tile.widthAnchor.constraint(equalToConstant: 32),
|
||||||
|
tile.heightAnchor.constraint(equalToConstant: 32),
|
||||||
|
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
||||||
|
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor),
|
||||||
|
label.leadingAnchor.constraint(equalTo: tile.trailingAnchor, constant: 10),
|
||||||
|
label.centerYAnchor.constraint(equalTo: header.centerYAnchor)
|
||||||
|
])
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startQuiz() {
|
||||||
|
Haptics.selection()
|
||||||
|
let request = QuizSetupRequest(words: category.words)
|
||||||
navigationController?.pushViewController(QuizSetupViewController(request: request), animated: true)
|
navigationController?.pushViewController(QuizSetupViewController(request: request), animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateVisibleSpeakers() {
|
||||||
|
for cell in tableView.visibleCells.compactMap({ $0 as? WordCardCell }) {
|
||||||
|
cell.updateSpeakerState()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
extension WordListViewController: UITableViewDataSource, UITableViewDelegate {
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
category.words.count
|
category.words.count
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! WordCardCell
|
||||||
let word = category.words[indexPath.row]
|
let word = category.words[indexPath.row]
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
cell.configure(with: word)
|
||||||
var content = cell.defaultContentConfiguration()
|
cell.onSpeak = { word in
|
||||||
content.text = word.word
|
SpeechService.shared.speak(word.word)
|
||||||
content.textProperties.font = .preferredFont(forTextStyle: .headline)
|
}
|
||||||
content.secondaryText = word.meaning
|
|
||||||
content.secondaryTextProperties.color = .secondaryLabel
|
|
||||||
cell.contentConfiguration = content
|
|
||||||
cell.accessoryType = .disclosureIndicator
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
Haptics.selection()
|
||||||
navigationController?.pushViewController(WordDetailViewController(word: category.words[indexPath.row]), animated: true)
|
navigationController?.pushViewController(
|
||||||
|
WordDetailViewController(word: category.words[indexPath.row]), animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user