diff --git a/LearnEnglish/LearnEnglish/Models/Quiz.swift b/LearnEnglish/LearnEnglish/Models/Quiz.swift index 332152e..4e14821 100644 --- a/LearnEnglish/LearnEnglish/Models/Quiz.swift +++ b/LearnEnglish/LearnEnglish/Models/Quiz.swift @@ -23,6 +23,7 @@ struct QuizQuestion: Identifiable { let options: [String] let answer: String let blankedWord: String + let blankPositions: [Int] } enum QuizGenerator { @@ -30,12 +31,15 @@ enum QuizGenerator { words.shuffled().map { word in let distractors = words.filter { $0 != word }.shuffled().prefix(3) let options = (distractors.map { optionText($0, mode) } + [optionText(word, mode)]).shuffled() + let blankedWord = mode == .spelling ? blankWord(word.word) : "" + let blankPositions = blankedWord.enumerated().filter { $0.element == "_" }.map { $0.offset } return QuizQuestion( word: word, prompt: promptText(word, mode), options: options, answer: optionText(word, mode), - blankedWord: mode == .spelling ? blankWord(word.word) : "" + blankedWord: blankedWord, + blankPositions: blankPositions ) } } diff --git a/LearnEnglish/LearnEnglish/Views/QuizView.swift b/LearnEnglish/LearnEnglish/Views/QuizView.swift index a8ae4a6..22bf03a 100644 --- a/LearnEnglish/LearnEnglish/Views/QuizView.swift +++ b/LearnEnglish/LearnEnglish/Views/QuizView.swift @@ -9,7 +9,8 @@ struct QuizView: View { @State private var questions: [QuizQuestion] @State private var currentIndex = 0 @State private var selectedOption: String? - @State private var userInput = "" + @State private var filledBlanks: [String] = [] + @State private var currentBlankIndex = 0 @State private var wrongWords: [Word] = [] @State private var finished = false @State private var startDate = Date.now @@ -39,6 +40,25 @@ struct QuizView: View { } .navigationTitle(config.mode.rawValue) .navigationBarTitleDisplayMode(.inline) + .onChange(of: current?.id) { _, _ in + resetBlanks() + } + .onChange(of: currentBlankIndex) { _, _ in + guard config.mode == .spelling, let question = current else { return } + if currentBlankIndex == question.blankPositions.count { + let typed = filledBlanks.joined() + selectedOption = typed + if typed != question.answer { + wrongWords.append(question.word) + } + } + } + } + + private func resetBlanks() { + guard config.mode == .spelling, let question = current else { return } + filledBlanks = Array(repeating: "", count: question.blankPositions.count) + currentBlankIndex = 0 } private func questionView(_ question: QuizQuestion) -> some View { @@ -132,28 +152,17 @@ struct QuizView: View { private func spellingView(_ question: QuizQuestion) -> some View { VStack(spacing: 16) { - Text(question.blankedWord) - .font(.system(size: 44, weight: .bold, design: .monospaced)) - .tracking(6) Text("\(question.word.word.count) 个字母") .font(.subheadline) .foregroundStyle(.secondary) - HStack(spacing: 12) { - TextField("输入完整单词", text: $userInput) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .disableAutocorrection(true) - .disabled(selectedOption != nil) - Button("确认") { - guard !userInput.trimmingCharacters(in: .whitespaces).isEmpty else { return } - selectedOption = userInput.trimmingCharacters(in: .whitespaces).lowercased() - if selectedOption != question.answer { - wrongWords.append(question.word) - } - } - .buttonStyle(.borderedProminent) - .disabled(userInput.trimmingCharacters(in: .whitespaces).isEmpty || selectedOption != nil) - } + SpellingWordInput( + blankedWord: question.blankedWord, + blankPositions: question.blankPositions, + currentBlankIndex: $currentBlankIndex, + filledBlanks: $filledBlanks + ) + .disabled(selectedOption != nil) + .opacity(selectedOption != nil ? 0.6 : 1) } } @@ -176,7 +185,6 @@ struct QuizView: View { if currentIndex + 1 < questions.count { currentIndex += 1 selectedOption = nil - userInput = "" } else { finished = true } @@ -186,7 +194,8 @@ struct QuizView: View { questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words) currentIndex = 0 selectedOption = nil - userInput = "" + filledBlanks = [] + currentBlankIndex = 0 wrongWords = [] finished = false startDate = .now diff --git a/LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift b/LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift new file mode 100644 index 0000000..d969081 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift @@ -0,0 +1,141 @@ +import SwiftUI +import UIKit + +// Captures keystrokes one at a time via hidden UITextField delegate +struct KeystrokeCaptureField: UIViewRepresentable { + let onChar: (Character) -> Void + let onBackspace: () -> Void + + func makeUIView(context: Context) -> UITextField { + let tf = UITextField() + tf.delegate = context.coordinator + tf.autocorrectionType = .no + tf.autocapitalizationType = .none + tf.spellCheckingType = .no + return tf + } + + func makeCoordinator() -> Coordinator { + Coordinator(onChar: onChar, onBackspace: onBackspace) + } + + func updateUIView(_ uiView: UITextField, context: Context) {} + + class Coordinator: NSObject, UITextFieldDelegate { + let onChar: (Character) -> Void + let onBackspace: () -> Void + + init(onChar: @escaping (Character) -> Void, onBackspace: @escaping () -> Void) { + self.onChar = onChar + self.onBackspace = onBackspace + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if string.isEmpty { + onBackspace() + } else if let char = string.last { + onChar(char) + } + textField.text = nil + return false + } + } +} + +// Blinking cursor on the active blank +struct BlinkingCursor: View { + var body: some View { + TimelineView(.periodic(from: .now, by: 0.6)) { timeline in + let visible = Int(timeline.date.timeIntervalSinceReferenceDate / 0.6) % 2 == 0 + Rectangle() + .fill(.tint) + .frame(width: 2, height: 22) + .opacity(visible ? 1 : 0) + } + } +} + +// Interactive word input with per-blank slots +struct SpellingWordInput: View { + let blankedWord: String + let blankPositions: [Int] + @Binding var currentBlankIndex: Int + @Binding var filledBlanks: [String] + @FocusState private var isFocused + + private let chars: [String] + + init(blankedWord: String, blankPositions: [Int], currentBlankIndex: Binding, filledBlanks: Binding<[String]>) { + self.blankedWord = blankedWord + self.blankPositions = blankPositions + self._currentBlankIndex = currentBlankIndex + self._filledBlanks = filledBlanks + self.chars = blankedWord.map { String($0) } + } + + var body: some View { + VStack(spacing: 8) { + KeystrokeCaptureField( + onChar: { char in + guard currentBlankIndex < blankPositions.count else { return } + filledBlanks[currentBlankIndex] = String(char).lowercased() + currentBlankIndex += 1 + }, + onBackspace: { + guard currentBlankIndex > 0 else { return } + currentBlankIndex -= 1 + filledBlanks[currentBlankIndex] = "" + } + ) + .frame(width: 0, height: 0) + .opacity(0.01) + .focused($isFocused) + + HStack(spacing: 4) { + ForEach(Array(chars.enumerated()), id: \.offset) { index, ch in + if ch == "_" { + blankSlot(index: index) + } else { + Text(ch.uppercased()) + .font(.title2.weight(.bold)) + .frame(width: 22) + } + } + } + } + .onTapGesture { isFocused = true } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isFocused = true + } + } + } + + @ViewBuilder + private func blankSlot(index: Int) -> some View { + let blankIndex = blankPositions.firstIndex(of: index) ?? 0 + let isCurrent = blankIndex == currentBlankIndex && isFocused + let filled = blankIndex < filledBlanks.count ? filledBlanks[blankIndex] : nil + + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(.quaternary.opacity(0.2)) + .frame(width: 28, height: 40) + RoundedRectangle(cornerRadius: 6) + .stroke(.quaternary, lineWidth: 1) + .frame(width: 28, height: 40) + if isCurrent { + RoundedRectangle(cornerRadius: 6) + .stroke(.tint, lineWidth: 2) + .frame(width: 28, height: 40) + } + + if let filled, !filled.isEmpty { + Text(filled.uppercased()) + .font(.title2.weight(.bold)) + } else if isCurrent { + BlinkingCursor() + } + } + } +}