feat: new file structure
This commit is contained in:
51
QuizApp/Models/QuizQuestion.swift
Normal file
51
QuizApp/Models/QuizQuestion.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// Topic.swift
|
||||
// QuizApp
|
||||
//
|
||||
// Created by Paul on 03.08.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct QuizQuestion: Decodable, Hashable, Identifiable {
|
||||
let id: UUID
|
||||
let question: String
|
||||
let answers: [String]
|
||||
let correctAnswer: Int
|
||||
|
||||
let type: String?
|
||||
let minValue: Double?
|
||||
let maxValue: Double?
|
||||
let correctValue: Double?
|
||||
let unit: String?
|
||||
|
||||
// Manuelles Decoding, id wird generiert
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
question = try container.decode(String.self, forKey: .question)
|
||||
|
||||
// Falls im JSON nicht vorhanden, Standard setzen
|
||||
answers = (try? container.decode([String].self, forKey: .answers)) ?? []
|
||||
correctAnswer = (try? container.decode(Int.self, forKey: .correctAnswer)) ?? 0
|
||||
|
||||
// Optional
|
||||
type = try container.decodeIfPresent(String.self, forKey: .type)
|
||||
minValue = try container.decodeIfPresent(Double.self, forKey: .minValue)
|
||||
maxValue = try container.decodeIfPresent(Double.self, forKey: .maxValue)
|
||||
correctValue = try container.decodeIfPresent(Double.self, forKey: .correctValue)
|
||||
unit = try container.decodeIfPresent(String.self, forKey: .unit)
|
||||
|
||||
id = UUID()
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case question, answers, correctAnswer
|
||||
case type, minValue, maxValue, correctValue, unit
|
||||
}
|
||||
|
||||
var isEstimation: Bool {
|
||||
if let t = type?.lowercased() { return t == "estimation" }
|
||||
return !answers.isEmpty ? false : (minValue != nil || maxValue != nil || correctValue != nil)
|
||||
}
|
||||
}
|
||||
@@ -25,17 +25,17 @@ class ViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
var currentQuestion: QuizQuestion? {
|
||||
guard currentQuestionIndex < questions.count else { return nil }
|
||||
return questions[currentQuestionIndex]
|
||||
}
|
||||
guard currentQuestionIndex < questions.count else { return nil }
|
||||
return questions[currentQuestionIndex]
|
||||
}
|
||||
|
||||
var availableCategories: [String] {
|
||||
return Array(allQuestionsByCategory.keys).sorted()
|
||||
}
|
||||
|
||||
|
||||
|
||||
init() {
|
||||
guard let url = Bundle.main.url(forResource: "data", withExtension: "json") else {
|
||||
guard let url = Bundle.main.url(forResource: "questions", withExtension: "json") else {
|
||||
questions = []
|
||||
return
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class ViewModel: ObservableObject {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
allQuestionsByCategory = try JSONDecoder().decode([String: [QuizQuestion]].self, from: data)
|
||||
print("Fragen geladen")
|
||||
} catch {
|
||||
print("Fehler beim Laden des Inhalts: \(error)")
|
||||
allQuestionsByCategory = [:]
|
||||
@@ -66,7 +67,7 @@ class ViewModel: ObservableObject {
|
||||
answeredCount = 0
|
||||
selectedAnswers = Array(repeating: nil, count: questions.count)
|
||||
}
|
||||
|
||||
|
||||
|
||||
func incrementScore(selectedIndex: Int) {
|
||||
if let correct = currentQuestion?.correctAnswer, selectedIndex == correct {
|
||||
@@ -20,115 +20,70 @@ struct ContentView: View {
|
||||
@State private var playerName: String = ""
|
||||
@State private var scoreIsSaved = false
|
||||
|
||||
@State private var estimationValue: Double = 0
|
||||
@State private var estimationSubmitted: Bool = false
|
||||
@State private var estimationPoints: Int = 0
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if viewModel.isQuizFinished {
|
||||
VStack(spacing: 20) {
|
||||
Text("🎉 Quiz beendet!")
|
||||
.font(.title)
|
||||
|
||||
Text("Dein Ergebnis: \(viewModel.score) von \(viewModel.questions.count) Punkten")
|
||||
.font(.headline)
|
||||
|
||||
Button("Nochmal spielen") {
|
||||
restartQuiz()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
|
||||
Button("Zurück zur Kategorieauswahl") {
|
||||
dismiss()
|
||||
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
|
||||
TextField("Dein Name:", text: $playerName)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.padding(.horizontal)
|
||||
|
||||
Button("Score speichern") {
|
||||
QuizFinishedView(
|
||||
score: viewModel.score,
|
||||
total: viewModel.questions.count,
|
||||
playerName: $playerName,
|
||||
onPlayAgain: { restartQuiz() },
|
||||
onBackToCategories: { dismiss() },
|
||||
onSaveScore: {
|
||||
saveScore()
|
||||
dismiss()
|
||||
}
|
||||
.padding()
|
||||
.background(playerName.isEmpty ? Color.gray : Color.green)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
.disabled(playerName.isEmpty)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
AudioServicesPlaySystemSound(1322)
|
||||
score += viewModel.score
|
||||
}
|
||||
} else if let frage = viewModel.currentQuestion {
|
||||
Text("Punkte: \(viewModel.score)")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
//Fortschrittsbalken
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<viewModel.questions.count, id: \.self) { index in
|
||||
Rectangle()
|
||||
.fill(colorForAnswer(at: index))
|
||||
.frame(width: 20, height: index == viewModel.currentQuestionIndex ? 14 : 10)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Frage \(viewModel.currentQuestionIndex + 1) von \(viewModel.questions.count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
QuizHeader(
|
||||
score: viewModel.score,
|
||||
currentIndex: viewModel.currentQuestionIndex,
|
||||
total: viewModel.questions.count,
|
||||
colorForStep: { idx in colorForAnswer(at: idx) } // nutzt deine bestehende Funktion
|
||||
)
|
||||
|
||||
Text(frage.question)
|
||||
.font(.title)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
|
||||
ForEach(frage.answers.indices, id: \.self) { index in
|
||||
Button(action: {
|
||||
selectedAnswerIndex = index
|
||||
isAnswered = true
|
||||
print("Selected:", index)
|
||||
print("Correct:", frage.correctAnswer)
|
||||
viewModel.incrementScore(selectedIndex: index)
|
||||
if frage.isEstimation,
|
||||
let minV = frage.minValue,
|
||||
let maxV = frage.maxValue,
|
||||
let correctV = frage.correctValue {
|
||||
// Estimation
|
||||
EstimationQuestionView(
|
||||
minValue: minV,
|
||||
maxValue: maxV,
|
||||
correctValue: correctV,
|
||||
unit: frage.unit ?? "",
|
||||
value: $estimationValue,
|
||||
submitted: $estimationSubmitted,
|
||||
gainedPoints: $estimationPoints
|
||||
) { gained in
|
||||
viewModel.score += gained
|
||||
viewModel.answeredCount += 1
|
||||
viewModel.selectedAnswers[viewModel.currentQuestionIndex] = index
|
||||
}) {
|
||||
Text(frage.answers[index])
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxWidth: 300)
|
||||
.background(buttonColor(for: index, correctIndex: frage.correctAnswer))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
.scaleEffect(isAnswered && selectedAnswerIndex == index ? 1.05 : 1.0)
|
||||
.shadow(radius: isAnswered && selectedAnswerIndex == index ? 5 : 2)
|
||||
isAnswered = true
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: isAnswered)
|
||||
.disabled(isAnswered)
|
||||
|
||||
}
|
||||
VStack {
|
||||
if isAnswered {
|
||||
Text(selectedAnswerIndex == frage.correctAnswer ? "✅ Richtig!" : "❌ Falsch")
|
||||
.font(.headline)
|
||||
.foregroundColor(selectedAnswerIndex == frage.correctAnswer ? .green : .red)
|
||||
.padding()
|
||||
}
|
||||
if isAnswered {
|
||||
Button(action: {
|
||||
if estimationSubmitted {
|
||||
Button {
|
||||
viewModel.loadNextQuestion()
|
||||
selectedAnswerIndex = nil
|
||||
// Reset für nächste Estimation
|
||||
estimationSubmitted = false
|
||||
isAnswered = false
|
||||
}) {
|
||||
selectedAnswerIndex = nil
|
||||
estimationPoints = 0
|
||||
} label: {
|
||||
Text("Nächste Frage")
|
||||
.font(.headline)
|
||||
.padding(.vertical, 10)
|
||||
@@ -137,9 +92,33 @@ struct ContentView: View {
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
} else {
|
||||
// Multiple-Choice
|
||||
MultipleChoiceQuestionView(
|
||||
answers: frage.answers,
|
||||
correctIndex: frage.correctAnswer,
|
||||
selectedIndex: $selectedAnswerIndex,
|
||||
isAnswered: $isAnswered,
|
||||
onAnswer: { index in
|
||||
print("Selected:", index)
|
||||
print("Correct:", frage.correctAnswer)
|
||||
viewModel.incrementScore(selectedIndex: index)
|
||||
viewModel.answeredCount += 1
|
||||
viewModel.selectedAnswers[viewModel.currentQuestionIndex] = index
|
||||
},
|
||||
buttonColor: { idx, correct in
|
||||
buttonColor(for: idx, correctIndex: correct)
|
||||
},
|
||||
onNext: {
|
||||
viewModel.loadNextQuestion()
|
||||
selectedAnswerIndex = nil
|
||||
isAnswered = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: 100)
|
||||
} else {
|
||||
Text("Fragen werden geladen...")
|
||||
}
|
||||
@@ -234,6 +213,17 @@ struct ContentView: View {
|
||||
|
||||
print("Score gespeichert: \(playerName) - \(viewModel.score) Punkte")
|
||||
}
|
||||
|
||||
private func pointsForEstimation(guess: Double, correct: Double, minValue: Double, maxValue: Double, maxPoints: Int = 3, thresholdPercent: Double = 0.3) -> Int {
|
||||
let span = maxValue - minValue
|
||||
guard span > 0 else { return 0 }
|
||||
let absError = abs(guess - correct)
|
||||
let relError = absError / span
|
||||
if relError >= thresholdPercent { return 0 }
|
||||
let ratio = 1.0 - (relError / thresholdPercent)
|
||||
return max(0, Int(round(Double(maxPoints) * ratio)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
99
QuizApp/Views/EstimationQuestionView.swift
Normal file
99
QuizApp/Views/EstimationQuestionView.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// EstimationQuestionView.swift
|
||||
// QuizApp
|
||||
//
|
||||
// Created by Paul on 08.08.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EstimationQuestionView: View {
|
||||
let minValue: Double
|
||||
let maxValue: Double
|
||||
let correctValue: Double
|
||||
let unit: String
|
||||
|
||||
@Binding var value: Double
|
||||
@Binding var submitted: Bool
|
||||
@Binding var gainedPoints: Int
|
||||
let onSubmit: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { value },
|
||||
set: { value = min(max($0, minValue), maxValue) } // clamp
|
||||
),
|
||||
in: minValue...maxValue,
|
||||
step: (maxValue - minValue) > 200 ? 1 : 0.5
|
||||
)
|
||||
.onAppear {
|
||||
if value < minValue || value > maxValue {
|
||||
value = (minValue + maxValue) / 2
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("\(Int(minValue)) \(unit)")
|
||||
Spacer()
|
||||
Text("\(Int(value)) \(unit)").font(.headline)
|
||||
Spacer()
|
||||
Text("\(Int(maxValue)) \(unit)")
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
if !submitted {
|
||||
Button {
|
||||
gainedPoints = pointsForEstimation(
|
||||
guess: value,
|
||||
correct: correctValue,
|
||||
minValue: minValue,
|
||||
maxValue: maxValue
|
||||
)
|
||||
submitted = true
|
||||
onSubmit(gainedPoints)
|
||||
} label: {
|
||||
Text("Antwort abgeben")
|
||||
.font(.headline)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 30)
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
} else {
|
||||
let diff = abs(value - correctValue)
|
||||
let span = maxValue - minValue
|
||||
let rel = span > 0 ? (diff / span) : 1.0
|
||||
|
||||
Text("✅ Richtiger Wert: \(Int(correctValue)) \(unit)")
|
||||
.font(.headline)
|
||||
.foregroundColor(.green)
|
||||
Text("Dein Tipp: \(Int(value)) \(unit) — Abweichung: \(Int(diff)) \(unit) (~\(Int(rel*100))%)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Erhaltene Punkte: \(gainedPoints)")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private func pointsForEstimation(
|
||||
guess: Double,
|
||||
correct: Double,
|
||||
minValue: Double,
|
||||
maxValue: Double,
|
||||
maxPoints: Int = 3,
|
||||
thresholdPercent: Double = 0.3
|
||||
) -> Int {
|
||||
let span = maxValue - minValue
|
||||
guard span > 0 else { return 0 }
|
||||
let absError = abs(guess - correct)
|
||||
let relError = absError / span
|
||||
if relError >= thresholdPercent { return 0 }
|
||||
let ratio = 1.0 - (relError / thresholdPercent)
|
||||
return max(0, Int(round(Double(maxPoints) * ratio)))
|
||||
}
|
||||
66
QuizApp/Views/MultipleChoiceQuestionView.swift
Normal file
66
QuizApp/Views/MultipleChoiceQuestionView.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// MultipleChoiceQuestionView.swift
|
||||
// QuizApp
|
||||
//
|
||||
// Created by Paul on 08.08.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MultipleChoiceQuestionView: View {
|
||||
let answers: [String]
|
||||
let correctIndex: Int
|
||||
@Binding var selectedIndex: Int?
|
||||
@Binding var isAnswered: Bool
|
||||
let onAnswer: (Int) -> Void
|
||||
let buttonColor: (Int, Int) -> Color
|
||||
|
||||
let onNext: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ForEach(answers.indices, id: \.self) { index in
|
||||
Button {
|
||||
selectedIndex = index
|
||||
isAnswered = true
|
||||
onAnswer(index)
|
||||
} label: {
|
||||
Text(answers[index])
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxWidth: 300)
|
||||
.background(buttonColor(index, correctIndex))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
.scaleEffect(isAnswered && selectedIndex == index ? 1.05 : 1.0)
|
||||
.shadow(radius: isAnswered && selectedIndex == index ? 5 : 2)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: isAnswered)
|
||||
.disabled(isAnswered)
|
||||
}
|
||||
|
||||
VStack {
|
||||
if isAnswered, let selected = selectedIndex {
|
||||
let correct = (selected == correctIndex)
|
||||
Text(correct ? "✅ Richtig!" : "❌ Falsch")
|
||||
.font(.headline)
|
||||
.foregroundColor(correct ? .green : .red)
|
||||
.padding()
|
||||
}
|
||||
if isAnswered {
|
||||
Button(action: onNext) {
|
||||
Text("Nächste Frage")
|
||||
.font(.headline)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 30)
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
50
QuizApp/Views/QuizFinishedView.swift
Normal file
50
QuizApp/Views/QuizFinishedView.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// QuizFinishedView.swift
|
||||
// QuizApp
|
||||
//
|
||||
// Created by Paul on 08.08.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct QuizFinishedView: View {
|
||||
let score: Int
|
||||
let total: Int
|
||||
@Binding var playerName: String
|
||||
let onPlayAgain: () -> Void
|
||||
let onBackToCategories: () -> Void
|
||||
let onSaveScore: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("🎉 Quiz beendet!")
|
||||
.font(.title)
|
||||
|
||||
Text("Dein Ergebnis: \(score) von \(total) Punkten")
|
||||
.font(.headline)
|
||||
|
||||
Button("Nochmal spielen", action: onPlayAgain)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
|
||||
Button("Zurück zur Kategorieauswahl", action: onBackToCategories)
|
||||
.padding()
|
||||
.background(Color.gray)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
|
||||
TextField("Dein Name:", text: $playerName)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.padding(.horizontal)
|
||||
|
||||
Button("Score speichern", action: onSaveScore)
|
||||
.padding()
|
||||
.background(playerName.isEmpty ? Color.gray : Color.green)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
.disabled(playerName.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
36
QuizApp/Views/QuizHeaderView.swift
Normal file
36
QuizApp/Views/QuizHeaderView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// QuizHeaderView.swift
|
||||
// QuizApp
|
||||
//
|
||||
// Created by Paul on 08.08.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct QuizHeader: View {
|
||||
let score: Int
|
||||
let currentIndex: Int
|
||||
let total: Int
|
||||
let colorForStep: (Int) -> Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Punkte: \(score)")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<total, id: \.self) { idx in
|
||||
Rectangle()
|
||||
.fill(colorForStep(idx))
|
||||
.frame(width: 20, height: idx == currentIndex ? 14 : 10)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Frage \(currentIndex + 1) von \(total)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user