Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions QuickQuiz/QuickQuiz/Models/Question.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
25 changes: 25 additions & 0 deletions QuickQuiz/QuickQuiz/Models/QuestionBank.swift
Original file line number Diff line number Diff line change
@@ -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."
)
]
}
12 changes: 12 additions & 0 deletions QuickQuiz/QuickQuiz/QuickQuizApp.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
64 changes: 64 additions & 0 deletions QuickQuiz/QuickQuiz/ViewModels/QuizViewModel.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
162 changes: 162 additions & 0 deletions QuickQuiz/QuickQuiz/Views/QuizView.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
44 changes: 44 additions & 0 deletions QuickQuiz/README.md
Original file line number Diff line number Diff line change
@@ -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`.