feat: new file structure

This commit is contained in:
2025-08-08 22:33:49 +02:00
parent 27fda7b3fa
commit 7b7717afe8
9 changed files with 386 additions and 93 deletions

View 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)
}
}

View File

@@ -35,7 +35,7 @@ class ViewModel: ObservableObject {
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 = [:]

View File

@@ -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 {

View 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)))
}

View 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)
}
}
}

View 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)
}
}
}

View 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)
}
}