diff --git a/QuickQuiz/QuickQuiz/Models/Question.swift b/QuickQuiz/QuickQuiz/Models/Question.swift new file mode 100644 index 0000000..92a33a4 --- /dev/null +++ b/QuickQuiz/QuickQuiz/Models/Question.swift @@ -0,0 +1,50 @@ +import Foundation + +struct Question: Identifiable, Equatable { + struct Option: Identifiable, Equatable { + let id: UUID + let text: String + + init(id: UUID = UUID(), text: String) { + self.id = id + self.text = text.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + let id: UUID + let prompt: String + let options: [Option] + let correctOptionID: Option.ID + let explanation: String? + + init( + id: UUID = UUID(), + prompt: String, + options: [Option], + correctOptionID: Option.ID, + explanation: String? = nil + ) { + precondition(!options.isEmpty, "Questions must offer at least one option") + precondition(options.contains(where: { $0.id == correctOptionID }), "Correct option must exist in options list") + + self.id = id + self.prompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + self.options = options + self.correctOptionID = correctOptionID + self.explanation = explanation?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + init(prompt: String, answers: [String], correctIndex: Int, explanation: String? = nil) { + precondition(!answers.isEmpty, "Questions must offer at least one option") + precondition(answers.indices.contains(correctIndex), "Correct answer index is out of bounds") + + let builtOptions = answers.map { Option(text: $0) } + let correctID = builtOptions[correctIndex].id + self.init( + prompt: prompt, + options: builtOptions, + correctOptionID: correctID, + explanation: explanation + ) + } +} diff --git a/QuickQuiz/QuickQuiz/Models/QuestionBank.swift b/QuickQuiz/QuickQuiz/Models/QuestionBank.swift new file mode 100644 index 0000000..5a598fe --- /dev/null +++ b/QuickQuiz/QuickQuiz/Models/QuestionBank.swift @@ -0,0 +1,25 @@ +import Foundation + +enum QuestionBank { + /// Replace the sample questions below with your own content. + static let defaultQuestions: [Question] = [ + Question( + prompt: "When did Apple introduce SwiftUI?", + answers: ["2014", "2016", "2019", "2021"], + correctIndex: 2, + explanation: "SwiftUI debuted during WWDC19." + ), + Question( + prompt: "Which keyword lets SwiftUI views refresh automatically when data changes?", + answers: ["@Binding", "@State", "@Published", "@ObservedObject"], + correctIndex: 1, + explanation: "@State keeps local view state and triggers updates." + ), + Question( + prompt: "What Xcode preview device most closely matches a standard iPhone size?", + answers: ["iPhone SE", "iPhone 15 Pro", "Apple Watch Ultra", "iPad mini"], + correctIndex: 1, + explanation: "The 6.1\" class (e.g. iPhone 15 Pro) is the common default." + ) + ] +} diff --git a/QuickQuiz/QuickQuiz/QuickQuizApp.swift b/QuickQuiz/QuickQuiz/QuickQuizApp.swift new file mode 100644 index 0000000..5b97d99 --- /dev/null +++ b/QuickQuiz/QuickQuiz/QuickQuizApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct QuickQuizApp: App { + @StateObject private var viewModel = QuizViewModel(questions: QuestionBank.defaultQuestions) + + var body: some Scene { + WindowGroup { + QuizView(viewModel: viewModel) + } + } +} diff --git a/QuickQuiz/QuickQuiz/ViewModels/QuizViewModel.swift b/QuickQuiz/QuickQuiz/ViewModels/QuizViewModel.swift new file mode 100644 index 0000000..c904ca8 --- /dev/null +++ b/QuickQuiz/QuickQuiz/ViewModels/QuizViewModel.swift @@ -0,0 +1,64 @@ +import Foundation +import SwiftUI + +final class QuizViewModel: ObservableObject { + @Published private(set) var questions: [Question] + @Published private(set) var currentIndex: Int = 0 + @Published private(set) var score: Int = 0 + @Published private(set) var isComplete: Bool = false + @Published var selectedOptionID: Question.Option.ID? = nil + @Published private(set) var hasAnswered: Bool = false + + init(questions: [Question]) { + precondition(!questions.isEmpty, "Provide at least one question") + self.questions = questions + } + + var currentQuestion: Question { + questions[currentIndex] + } + + var progressText: String { + "Question \(currentIndex + 1) of \(questions.count)" + } + + var scoreText: String { + "Score: \(score) / \(questions.count)" + } + + func select(option: Question.Option) { + guard !hasAnswered else { return } + selectedOptionID = option.id + hasAnswered = true + if option.id == currentQuestion.correctOptionID { + score += 1 + } + + if currentIndex == questions.count - 1 { + isComplete = true + } + } + + func goToNextQuestion() { + guard currentIndex < questions.count - 1 else { + isComplete = true + return + } + + currentIndex += 1 + selectedOptionID = nil + hasAnswered = false + } + + func restart(shuffleQuestions: Bool = true) { + if shuffleQuestions { + questions.shuffle() + } + + currentIndex = 0 + score = 0 + isComplete = false + selectedOptionID = nil + hasAnswered = false + } +} diff --git a/QuickQuiz/QuickQuiz/Views/QuizView.swift b/QuickQuiz/QuickQuiz/Views/QuizView.swift new file mode 100644 index 0000000..f438155 --- /dev/null +++ b/QuickQuiz/QuickQuiz/Views/QuizView.swift @@ -0,0 +1,162 @@ +import SwiftUI + +struct QuizView: View { + @ObservedObject var viewModel: QuizViewModel + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 24) { + header + questionCard + optionsList + explanationSection + Spacer() + actionButtons + } + .padding(24) + .background( + LinearGradient( + colors: [Color(.systemBackground), Color(.secondarySystemBackground)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + ) + .navigationTitle("Quick Quiz") + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Text(viewModel.progressText) + .font(.headline) + .foregroundStyle(.secondary) + ProgressView(value: Double(viewModel.currentIndex + 1), total: Double(viewModel.questions.count)) + Text(viewModel.scoreText) + .font(.subheadline) + .bold() + } + } + + private var questionCard: some View { + Text(viewModel.currentQuestion.prompt) + .font(.title2.weight(.semibold)) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 16).fill(Color(.tertiarySystemBackground))) + } + + private var optionsList: some View { + VStack(spacing: 12) { + ForEach(viewModel.currentQuestion.options) { option in + OptionButton( + option: option, + isSelected: option.id == viewModel.selectedOptionID, + isCorrect: option.id == viewModel.currentQuestion.correctOptionID, + hasAnswered: viewModel.hasAnswered + ) { + viewModel.select(option: option) + } + .disabled(viewModel.hasAnswered) + } + } + } + + private var explanationSection: some View { + Group { + if viewModel.hasAnswered, let explanation = viewModel.currentQuestion.explanation { + Text(explanation) + .font(.callout) + .foregroundStyle(.secondary) + .padding(.horizontal) + } + } + } + + private var actionButtons: some View { + VStack(spacing: 12) { + if viewModel.isComplete { + Button(action: { viewModel.restart() }) { + Label("Restart Quiz", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } else { + Button(action: viewModel.goToNextQuestion) { + Label("Next", systemImage: "chevron.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.hasAnswered) + } + + Button(action: { viewModel.restart(shuffleQuestions: false) }) { + Label("Start Over (same order)", systemImage: "arrow.counterclockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } +} + +private struct OptionButton: View { + let option: Question.Option + let isSelected: Bool + let isCorrect: Bool + let hasAnswered: Bool + let tapAction: () -> Void + + var body: some View { + Button(action: tapAction) { + HStack { + Text(option.text) + .font(.body.weight(.medium)) + .multilineTextAlignment(.leading) + Spacer() + if hasAnswered { + Image(systemName: isCorrect ? "checkmark.circle.fill" : (isSelected ? "xmark.circle.fill" : "circle")) + .foregroundStyle(iconColor) + } + } + .padding() + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .background(RoundedRectangle(cornerRadius: 14).fill(backgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(borderColor, lineWidth: 1) + ) + } + + private var backgroundColor: Color { + guard hasAnswered else { + return Color(.secondarySystemBackground) + } + if isCorrect { + return Color.green.opacity(0.2) + } + if isSelected { + return Color.red.opacity(0.2) + } + return Color(.secondarySystemBackground) + } + + private var borderColor: Color { + guard hasAnswered else { + return Color.clear + } + return isCorrect ? .green : (isSelected ? .red : .clear) + } + + private var iconColor: Color { + isCorrect ? .green : (isSelected ? .red : .secondary) + } +} + +#Preview +struct QuizView_Previews: PreviewProvider { + static var previews: some View { + QuizView(viewModel: QuizViewModel(questions: QuestionBank.defaultQuestions)) + } +} diff --git a/QuickQuiz/README.md b/QuickQuiz/README.md new file mode 100644 index 0000000..4b8fa2b --- /dev/null +++ b/QuickQuiz/README.md @@ -0,0 +1,44 @@ +# QuickQuiz + +Lightweight SwiftUI multiple-choice quiz that works entirely on-device. There is no login, backend, or analytics—just hard-coded questions you can edit in seconds. + +## Project layout + +``` +QuickQuiz/ +├─ QuickQuizApp.swift // Entry point +├─ Models/ +│ ├─ Question.swift // Data structures for questions & answers +│ └─ QuestionBank.swift // Sample questions to edit later +├─ ViewModels/ +│ └─ QuizViewModel.swift // Quiz state machine (progress, scoring) +└─ Views/ + └─ QuizView.swift // UI for asking questions and selecting answers +``` + +## How to get it running in < 5 minutes + +1. **Create a new SwiftUI app in Xcode** (File → New → Project → iOS App). Name it `QuickQuiz` (or anything you like) and choose "SwiftUI" for the interface. +2. **Copy the files from this folder into your project**: + - Replace the autogenerated `App` file with `QuickQuizApp.swift`. + - Add the `Models`, `ViewModels`, and `Views` folders (right-click your Xcode project → "Add Files to ..." → select the folders). +3. **Build & run** on the simulator or your plugged-in iPhone. Code-signing is already handled by your existing Apple developer setup. + +## Editing the question later + +Open `QuestionBank.swift` and replace the sample `Question` entries with your real content. Each `Question` takes: +```swift +Question( + prompt: "What is 2 + 2?", + answers: ["3", "4", "5", "6"], + correctIndex: 1, + explanation: "Because 2 + 2 = 4." +) +``` +Add as many questions as you need; the UI automatically updates the progress tracker and score. + +## Notes + +- Everything is computed on-device, so it works offline. +- `QuizViewModel` includes a quick `restart()` helper that reshuffles the questions; hook it up to any button if you want different behavior. +- Feel free to theme the colors or typography—everything lives in `QuizView`.