diff --git a/LearnEnglish/LearnEnglish/Models/AppRoute.swift b/LearnEnglish/LearnEnglish/Models/AppRoute.swift new file mode 100644 index 0000000..1bd8b58 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Models/AppRoute.swift @@ -0,0 +1,8 @@ +import Foundation + +enum AppRoute: Hashable { + case category(WordCategory) + case word(Word) + case quizSetup(QuizSetupRequest) + case quiz(QuizConfig) +} diff --git a/LearnEnglish/LearnEnglish/Models/Quiz.swift b/LearnEnglish/LearnEnglish/Models/Quiz.swift new file mode 100644 index 0000000..6c99eef --- /dev/null +++ b/LearnEnglish/LearnEnglish/Models/Quiz.swift @@ -0,0 +1,47 @@ +import Foundation + +enum QuizMode: String, Hashable { + case enToZh = "英选汉" + case zhToEn = "汉选英" +} + +struct QuizSetupRequest: Hashable { + let mode: QuizMode + let words: [Word] +} + +struct QuizConfig: Hashable { + let mode: QuizMode + let words: [Word] +} + +struct QuizQuestion: Identifiable { + var id: String { word.id } + let word: Word + let prompt: String + let options: [String] + let answer: String +} + +enum QuizGenerator { + static func makeQuestions(mode: QuizMode, words: [Word]) -> [QuizQuestion] { + 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() + return QuizQuestion( + word: word, + prompt: promptText(word, mode), + options: options, + answer: optionText(word, mode) + ) + } + } + + private static func promptText(_ word: Word, _ mode: QuizMode) -> String { + mode == .enToZh ? word.word : word.meaning + } + + private static func optionText(_ word: Word, _ mode: QuizMode) -> String { + mode == .enToZh ? word.meaning : word.word + } +} diff --git a/LearnEnglish/LearnEnglish/Views/CategoryListView.swift b/LearnEnglish/LearnEnglish/Views/CategoryListView.swift index 9825b0f..2ed7f77 100644 --- a/LearnEnglish/LearnEnglish/Views/CategoryListView.swift +++ b/LearnEnglish/LearnEnglish/Views/CategoryListView.swift @@ -2,11 +2,12 @@ import SwiftUI struct CategoryListView: View { let categories: [WordCategory] + @State private var path: [AppRoute] = [] var body: some View { - NavigationStack { + NavigationStack(path: $path) { List(categories) { category in - NavigationLink(value: category) { + NavigationLink(value: AppRoute.category(category)) { HStack(spacing: 16) { Image(systemName: category.icon) .font(.title2) @@ -25,8 +26,17 @@ struct CategoryListView: View { } } .navigationTitle("单词分类") - .navigationDestination(for: WordCategory.self) { category in - WordListView(category: category) + .navigationDestination(for: AppRoute.self) { route in + switch route { + case .category(let category): + WordListView(category: category) { path.append($0) } + case .word(let word): + WordDetailView(word: word) + case .quizSetup(let request): + QuizSetupView(request: request) + case .quiz(let config): + QuizView(config: config) + } } } } diff --git a/LearnEnglish/LearnEnglish/Views/QuizResultView.swift b/LearnEnglish/LearnEnglish/Views/QuizResultView.swift new file mode 100644 index 0000000..1f4b834 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/QuizResultView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct QuizResultView: View { + let total: Int + let wrongWords: [Word] + let duration: TimeInterval + let onRestart: () -> Void + let onExit: () -> Void + + private var correctCount: Int { total - wrongWords.count } + + private var durationText: String { + let seconds = Int(duration) + return seconds >= 60 ? "\(seconds / 60) 分 \(seconds % 60) 秒" : "\(seconds) 秒" + } + + var body: some View { + List { + Section { + VStack(spacing: 8) { + Text("\(correctCount) / \(total)") + .font(.system(size: 44, weight: .bold)) + Text("用时 \(durationText)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + + if !wrongWords.isEmpty { + Section("错题(\(wrongWords.count))") { + ForEach(wrongWords) { word in + NavigationLink(value: AppRoute.word(word)) { + HStack { + Text(word.word) + .font(.headline) + Spacer() + Text(word.meaning) + .foregroundStyle(.secondary) + } + } + } + } + } + + Section { + Button("再来一轮", action: onRestart) + .frame(maxWidth: .infinity) + Button("返回", action: onExit) + .frame(maxWidth: .infinity) + .foregroundStyle(.secondary) + } + } + } +} + +#Preview { + NavigationStack { + QuizResultView( + total: 10, + wrongWords: [ + Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。") + ], + duration: 75, + onRestart: {}, + onExit: {} + ) + } +} diff --git a/LearnEnglish/LearnEnglish/Views/QuizSetupView.swift b/LearnEnglish/LearnEnglish/Views/QuizSetupView.swift new file mode 100644 index 0000000..9e6e364 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/QuizSetupView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct QuizSetupView: View { + let request: QuizSetupRequest + + var body: some View { + List { + NavigationLink(value: AppRoute.quiz(QuizConfig(mode: request.mode, words: request.words))) { + VStack(alignment: .leading, spacing: 4) { + Text("全部测试") + .font(.headline) + Text("\(request.words.count) 题") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + NavigationLink(value: AppRoute.quiz(QuizConfig(mode: request.mode, words: Array(request.words.shuffled().prefix(10))))) { + VStack(alignment: .leading, spacing: 4) { + Text("随机十个") + .font(.headline) + Text("从词库中随机抽取 10 题") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + .navigationTitle(request.mode.rawValue) + } +} + +#Preview { + NavigationStack { + QuizSetupView(request: QuizSetupRequest(mode: .enToZh, words: [ + Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。") + ])) + } +} diff --git a/LearnEnglish/LearnEnglish/Views/QuizView.swift b/LearnEnglish/LearnEnglish/Views/QuizView.swift new file mode 100644 index 0000000..911a1b0 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/QuizView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +struct QuizView: View { + let config: QuizConfig + + @Environment(SpeechService.self) private var speech + @Environment(\.dismiss) private var dismiss + + @State private var questions: [QuizQuestion] = [] + @State private var currentIndex = 0 + @State private var selectedOption: String? + @State private var wrongWords: [Word] = [] + @State private var finished = false + @State private var startDate = Date.now + + private var current: QuizQuestion? { + questions.indices.contains(currentIndex) ? questions[currentIndex] : nil + } + + var body: some View { + Group { + if finished { + QuizResultView( + total: questions.count, + wrongWords: wrongWords, + duration: Date.now.timeIntervalSince(startDate), + onRestart: restart, + onExit: { dismiss() } + ) + } else if let question = current { + questionView(question) + } + } + .navigationTitle(config.mode.rawValue) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + if questions.isEmpty { restart() } + } + } + + private func questionView(_ question: QuizQuestion) -> some View { + VStack(spacing: 24) { + VStack(spacing: 8) { + ProgressView(value: Double(currentIndex), total: Double(questions.count)) + Text("第 \(currentIndex + 1) / \(questions.count) 题") + .font(.caption) + .foregroundStyle(.secondary) + } + + VStack(spacing: 12) { + Text(question.prompt) + .font(.system(size: config.mode == .enToZh ? 44 : 34, weight: .bold)) + if config.mode == .enToZh { + Button { + speech.speak(question.prompt) + } label: { + Image(systemName: "speaker.wave.2.fill") + .font(.title2) + } + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + + VStack(spacing: 12) { + ForEach(question.options, id: \.self) { option in + Button { + select(option, for: question) + } label: { + Text(option) + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.bordered) + .tint(optionColor(option, for: question)) + .disabled(selectedOption != nil) + } + } + + if let selectedOption { + VStack(spacing: 12) { + Text(selectedOption == question.answer ? "回答正确" : "正确答案:\(question.answer)") + .font(.headline) + .foregroundStyle(selectedOption == question.answer ? .green : .red) + Button(currentIndex + 1 < questions.count ? "下一题" : "查看成绩") { + next() + } + .buttonStyle(.borderedProminent) + } + } + + Spacer() + } + .padding() + } + + private func optionColor(_ option: String, for question: QuizQuestion) -> Color { + guard let selectedOption else { return .accentColor } + if option == question.answer { return .green } + if option == selectedOption { return .red } + return .gray + } + + private func select(_ option: String, for question: QuizQuestion) { + guard selectedOption == nil else { return } + selectedOption = option + if option != question.answer { + wrongWords.append(question.word) + } + } + + private func next() { + if currentIndex + 1 < questions.count { + currentIndex += 1 + selectedOption = nil + } else { + finished = true + } + } + + private func restart() { + questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words) + currentIndex = 0 + selectedOption = nil + wrongWords = [] + finished = false + startDate = .now + } +} + +#Preview { + NavigationStack { + QuizView(config: QuizConfig(mode: .enToZh, words: [ + Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。"), + Word(word: "blue", phonetic: "/bluː/", meaning: "蓝色", example: "The sky is blue.", exampleMeaning: "天空是蓝色的。"), + Word(word: "green", phonetic: "/ɡriːn/", meaning: "绿色", example: "The grass is green.", exampleMeaning: "草地是绿色的。"), + Word(word: "yellow", phonetic: "/ˈjeloʊ/", meaning: "黄色", example: "The banana is yellow.", exampleMeaning: "香蕉是黄色的。") + ])) + .environment(SpeechService()) + } +} diff --git a/LearnEnglish/LearnEnglish/Views/WordListView.swift b/LearnEnglish/LearnEnglish/Views/WordListView.swift index eea458f..9c71469 100644 --- a/LearnEnglish/LearnEnglish/Views/WordListView.swift +++ b/LearnEnglish/LearnEnglish/Views/WordListView.swift @@ -2,10 +2,11 @@ import SwiftUI struct WordListView: View { let category: WordCategory + let onRoute: (AppRoute) -> Void var body: some View { List(category.words) { word in - NavigationLink(value: word) { + NavigationLink(value: AppRoute.word(word)) { VStack(alignment: .leading, spacing: 4) { Text(word.word) .font(.headline) @@ -17,8 +18,23 @@ struct WordListView: View { } } .navigationTitle(category.name) - .navigationDestination(for: Word.self) { word in - WordDetailView(word: word) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + Button("英选汉") { startQuiz(.enToZh) } + Button("汉选英") { startQuiz(.zhToEn) } + } label: { + Label("测验", systemImage: "questionmark.circle") + } + } + } + } + + private func startQuiz(_ mode: QuizMode) { + if category.words.count <= 10 { + onRoute(.quiz(QuizConfig(mode: mode, words: category.words))) + } else { + onRoute(.quizSetup(QuizSetupRequest(mode: mode, words: category.words))) } } } @@ -27,6 +43,6 @@ struct WordListView: View { NavigationStack { WordListView(category: WordCategory(id: "colors", name: "颜色", icon: "paintpalette.fill", words: [ Word(word: "red", phonetic: "/red/", meaning: "红色", example: "The apple is red.", exampleMeaning: "苹果是红色的。") - ])) + ])) { _ in } } }