Generate one-paragraph Spanish stories on-device using Foundation Models, matched to user's level and enabled tenses. Every word is tappable — pre-annotated words show instantly, others get a quick on-device AI lookup with caching. English translation hidden by default behind a toggle. Comprehension quiz with 3 multiple-choice questions. Stories saved to cloud container for sync and persistence across resets. Closes #9 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
153 lines
5.2 KiB
Swift
153 lines
5.2 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
|
|
struct StoryQuizView: View {
|
|
let story: Story
|
|
|
|
@State private var currentIndex = 0
|
|
@State private var selectedOption: Int?
|
|
@State private var correctCount = 0
|
|
@State private var isFinished = false
|
|
|
|
private var questions: [QuizQuestion] { story.decodedQuestions }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 24) {
|
|
if isFinished {
|
|
finishedView
|
|
} else if let question = questions[safe: currentIndex] {
|
|
questionView(question)
|
|
}
|
|
}
|
|
.padding()
|
|
.adaptiveContainer(maxWidth: 600)
|
|
.navigationTitle("Comprehension Quiz")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
// MARK: - Question View
|
|
|
|
@ViewBuilder
|
|
private func questionView(_ question: QuizQuestion) -> some View {
|
|
VStack(spacing: 20) {
|
|
// Progress
|
|
Text("Question \(currentIndex + 1) of \(questions.count)")
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
|
|
// Question
|
|
Text(question.question)
|
|
.font(.title3.weight(.semibold))
|
|
.multilineTextAlignment(.center)
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
|
|
|
// Options
|
|
VStack(spacing: 10) {
|
|
ForEach(Array(question.options.enumerated()), id: \.offset) { index, option in
|
|
Button {
|
|
guard selectedOption == nil else { return }
|
|
selectedOption = index
|
|
if index == question.correctIndex {
|
|
correctCount += 1
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Text(option)
|
|
.font(.body)
|
|
.multilineTextAlignment(.leading)
|
|
Spacer()
|
|
if let selected = selectedOption {
|
|
if index == question.correctIndex {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
} else if index == selected {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(optionBackground(index: index, correct: question.correctIndex), in: RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Next button
|
|
if selectedOption != nil {
|
|
Button {
|
|
if currentIndex + 1 < questions.count {
|
|
currentIndex += 1
|
|
selectedOption = nil
|
|
} else {
|
|
withAnimation { isFinished = true }
|
|
}
|
|
} label: {
|
|
Text(currentIndex + 1 < questions.count ? "Next Question" : "See Results")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.teal)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Finished View
|
|
|
|
private var finishedView: some View {
|
|
VStack(spacing: 20) {
|
|
Spacer()
|
|
|
|
Image(systemName: correctCount == questions.count ? "star.fill" : "checkmark.circle")
|
|
.font(.system(size: 60))
|
|
.foregroundStyle(correctCount == questions.count ? .yellow : .teal)
|
|
|
|
Text("\(correctCount) / \(questions.count)")
|
|
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
|
|
|
Text(scoreMessage)
|
|
.font(.title3)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var scoreMessage: String {
|
|
switch correctCount {
|
|
case questions.count: return "Perfect score!"
|
|
case _ where correctCount > questions.count / 2: return "Good job! Keep reading."
|
|
default: return "Try re-reading the story and quiz again."
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func optionBackground(index: Int, correct: Int) -> some ShapeStyle {
|
|
guard let selected = selectedOption else {
|
|
return AnyShapeStyle(.fill.quaternary)
|
|
}
|
|
if index == correct {
|
|
return AnyShapeStyle(.green.opacity(0.15))
|
|
}
|
|
if index == selected {
|
|
return AnyShapeStyle(.red.opacity(0.15))
|
|
}
|
|
return AnyShapeStyle(.fill.quaternary)
|
|
}
|
|
}
|
|
|
|
private extension Collection {
|
|
subscript(safe index: Index) -> Element? {
|
|
indices.contains(index) ? self[index] : nil
|
|
}
|
|
}
|