import SwiftUI struct ExerciseScaffold: View { @ObservedObject var vm: AppModel let title: String var sub: String? let action: String var onAction: () -> Void let feedback: String let kind: String @ViewBuilder var content: () -> Content var body: some View { let p = vm.palette ScrollView { VStack(alignment: .leading, spacing: 8) { content() } .exerciseContent() } .scrollDismissesKeyboard(.interactively) .detailScreen(title, subtitle: sub) .safeAreaInset(edge: .bottom) { BottomAction(label: action, action: onAction) { if !feedback.isEmpty { FeedbackLine(text: feedback, kind: kind, palette: p) .frame(maxWidth: .infinity, alignment: .leading) } } } .animation(.default, value: feedback) } } struct GermanExercise: View { @ObservedObject var vm: AppModel let unitId: Int let ex: Int var body: some View { if let spec = vm.content.germanUnit(unitId)?.exercise(ex) { DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) { vm.markGerman(unitId, ex) } } } } struct VocabExercise: View { @ObservedObject var vm: AppModel let unitId: Int var body: some View { if let spec = vm.content.germanUnit(unitId)?.obj("vocab") { DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr("Vokabeltraining")) { vm.markVocab(unitId) } } } } struct MluvExercise: View { @ObservedObject var vm: AppModel let n: Int var body: some View { if let spec = vm.content.mluv(n) { DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) { vm.markMluv(n) } } } } struct DispatchExercise: View { @ObservedObject var vm: AppModel let spec: J let titleFallback: String var onDone: () -> Void var body: some View { let title = spec.str("title").isEmpty ? titleFallback : spec.str("title") let sub = spec.strOrNull("sub").map { vm.tr($0) } switch spec.str("type") { case "choice": ChoiceEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "assembly": AssemblyEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "typed", "kw": TypedEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "free", "profile": FreeEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "assign": AssignEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "hangman": HangmanEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "vocab": VocabEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "dialog": DialogEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "number", "count", "seq", "verb", "verbclue", "verb_sections", "ordne", "fill": ComboEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "ex2": Ex2Ex(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "letters", "letters_gap": LettersEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "table": TableEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "reveal": RevealEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) default: Text("?").foregroundStyle(.primary) } } } private struct ChoiceEx: View { @ObservedObject var vm: AppModel let title: String var sub: String? let spec: J var onDone: () -> Void @State private var picks: [Int] @State private var fb = ("", "") @State private var revealed = false init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { self.vm = vm self.title = title self.sub = sub self.spec = spec self.onDone = onDone _picks = State(initialValue: Array(repeating: -1, count: spec.arr("questions").count)) } var body: some View { let qs = spec.arr("questions") let meanings = spec.strs("meanings") let expls = spec.strs("expls") let p = vm.palette ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { let ok = qs.indices.filter { picks[$0] == qs[$0].int("correct") }.count if ok == qs.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() } else { fb = (vm.tr("feedback_retry_short"), "err") } revealed = true }, feedback: fb.0, kind: fb.1) { ForEach(qs.indices, id: \.self) { i in Text("\(i + 1). \(qs[i].str("prompt"))") .font(.body.weight(.semibold)) .padding(.bottom, 6) ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in let sel = picks[i] == o let mark: Bool? = revealed && sel ? picks[i] == qs[i].int("correct") : nil OptionChip(text: opt, selected: sel, mark: mark) { picks[i] = o } } if revealed { if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) } if i < expls.count { Meaning(text: expls[i], palette: p, visible: true) } } Spacer().frame(height: 10) } } } } private struct AssemblyEx: View { @ObservedObject var vm: AppModel let title: String var sub: String? let spec: J var onDone: () -> Void @State private var placed: [[Int]] @State private var pools: [[Int]] @State private var fb = ("", "") @State private var revealed = false init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { self.vm = vm self.title = title self.sub = sub self.spec = spec self.onDone = onDone let items = spec.arr("items") _placed = State(initialValue: items.map { _ in [] }) _pools = State(initialValue: items.map { item in Array(0.. Void @State private var fields: [[String]] @State private var fb = ("", "") @State private var revealed = false init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { self.vm = vm self.title = title self.sub = sub self.spec = spec self.onDone = onDone let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows") _fields = State(initialValue: items.map { q in Array(repeating: "", count: max(1, q.strs("hints").count, q.answerList().count)) }) } var body: some View { let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows") let p = vm.palette let umlaut = items.contains { q in q.answerList().contains { hasUmlaut($0) } || hasUmlaut(q.str("prompt")) } ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { var ok = 0, total = 0, incomplete = false for (i, q) in items.enumerated() { let answers = q.answerList() for (j, value) in fields[i].enumerated() { total += 1 let ans = j < answers.count ? answers[j] : "" let norm = normalizeAnswer(value) if answerAccepts(norm, ans) { ok += 1 } else if answerIncomplete(norm, ans) { incomplete = true } } } if total > 0 && ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() } else if incomplete { fb = (vm.tr("trans_incomplete"), "warn") } else { fb = (vm.tr("feedback_retry"), "err") } revealed = true }, feedback: fb.0, kind: fb.1) { if let note = spec.strOrNull("note") { Hint(text: note) } if let sample = spec.strOrNull("sample") { Hint(text: sample) } if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)") } if umlaut { Hint(text: vm.tr("hint_umlauts")) } ForEach(items.indices, id: \.self) { i in let q = items[i] Prompt(text: q.str("prompt")) let answers = q.answerList() let hints = q.strs("hints") ForEach(fields[i].indices, id: \.self) { j in let ans = j < answers.count ? answers[j] : "" WordField( value: Binding(get: { fields[i][j] }, set: { fields[i][j] = $0 }), placeholder: j < hints.count ? hints[j] : vm.tr("your_answer"), mark: revealed ? answerAccepts(normalizeAnswer(fields[i][j]), ans) : nil, palette: p ) .padding(.bottom, 4) } if revealed { if let m = q.strOrNull("meaning") { Meaning(text: vm.tr(m), palette: p, visible: true) } if let m = q.strOrNull("mean") { Meaning(text: vm.tr(m), palette: p, visible: true) } if let g = q.strOrNull("german") { Meaning(text: g, palette: p, visible: true) } if let s = q.strOrNull("shown") { Meaning(text: s, palette: p, visible: true) } } Spacer().frame(height: 10) } } } } private struct FreeEx: View { @ObservedObject var vm: AppModel let title: String var sub: String? let spec: J var onDone: () -> Void @State private var shown: [Bool] @State private var values: [String] @State private var fb = ("", "") init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { self.vm = vm self.title = title self.sub = sub self.spec = spec self.onDone = onDone let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions") _shown = State(initialValue: Array(repeating: false, count: qs.count)) _values = State(initialValue: Array(repeating: "", count: qs.count)) } var body: some View { let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions") let p = vm.palette ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: { fb = (vm.tr("feedback_ok"), "ok") shown = shown.map { _ in true } onDone() }, feedback: fb.0, kind: fb.1) { if let tip = spec.strOrNull("tip") { Hint(text: vm.tr(tip)) } ForEach(qs.indices, id: \.self) { i in let q = qs[i] Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question")) WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), placeholder: vm.tr("your_answer"), palette: p) PillButton(label: vm.tr("show_sample")) { shown[i].toggle() } .padding(.vertical, 6) if shown[i] { let sample = q.str("sample") let qcs = q.str("qCs") let acs = q.str("aCs") Meaning( text: qcs.isEmpty ? sample : vm.fmt("sample_fmt", vm.tr(qcs), sample, vm.tr(acs)), palette: p, visible: true ) } Spacer().frame(height: 10) } } } } private struct AssignEx: View { @ObservedObject var vm: AppModel let title: String var sub: String? let spec: J var onDone: () -> Void @State private var active = 0 @State private var loc: [Int] @State private var fb = ("", "") @State private var revealed = false init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { self.vm = vm self.title = title self.sub = sub self.spec = spec self.onDone = onDone _loc = State(initialValue: Array(repeating: -1, count: spec.arr("items").count)) } var body: some View { let items = spec.arr("items") let groups = spec.strs("groups") let meanings = spec.strs("meanings") let p = vm.palette ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { let ok = items.indices.allSatisfy { loc[$0] == items[$0].int("group") } if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() } else { fb = (vm.tr("feedback_retry"), "err") } revealed = true }, feedback: fb.0, kind: fb.1) { Hint(text: vm.tr("assign_hint")) FlowLayout(spacing: 8) { ForEach(groups.indices, id: \.self) { i in SegChip(label: groups[i], selected: active == i) { active = i } } } Text(vm.tr("wordbank")).font(.caption).foregroundStyle(.secondary).padding(.top, 10) FlowLayout(spacing: 6) { ForEach(items.indices, id: \.self) { i in if loc[i] == -1 { let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ") WordChip(text: label) { loc[i] = active } } } } ForEach(groups.indices, id: \.self) { g in Text(groups[g]).font(.caption.weight(.semibold)).foregroundStyle(.secondary).padding(.top, 10) FlowLayout(spacing: 6) { ForEach(items.indices, id: \.self) { i in if loc[i] == g { let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ") WordChip(text: label) { loc[i] = -1 } } } } } if revealed { ForEach(items.indices, id: \.self) { i in if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) } } } } } } private struct HangmanEx: View { @ObservedObject var vm: AppModel let title: String var sub: String? let spec: J var onDone: () -> Void @State private var word = 0 @State private var misses = 0 @State private var guessed: [Bool] @State private var fb = ("", "") @State private var finished = false init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { self.vm = vm self.title = title self.sub = sub self.spec = spec self.onDone = onDone _guessed = State(initialValue: Array(repeating: false, count: spec.strs("letters").count)) } var body: some View { let words = spec.strs("words") let tips = spec.strs("tips") let letters = spec.strs("letters") let p = vm.palette let cur = word < words.count ? words[word].uppercased() : "" ScrollView { VStack(alignment: .leading, spacing: 8) { Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(.secondary) if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))") } Canvas { ctx, size in var gallows = Path() gallows.move(to: CGPoint(x: 40, y: size.height - 10)) gallows.addLine(to: CGPoint(x: size.width * 0.55, y: size.height - 10)) gallows.move(to: CGPoint(x: 70, y: size.height - 10)) gallows.addLine(to: CGPoint(x: 70, y: 16)) gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, y: 16)) ctx.stroke(gallows, with: .color(.primary), style: StrokeStyle(lineWidth: 6, lineCap: .round)) let x = size.width * 0.3 + 40 if misses > 0 { var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36)) ctx.stroke(rope, with: .color(.primary), lineWidth: 4) } if misses > 1 { ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(.primary), lineWidth: 4) } if misses > 2 { var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110)) ctx.stroke(body, with: .color(.primary), lineWidth: 4) } if misses > 3 { var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100)) ctx.stroke(a, with: .color(.primary), lineWidth: 4) } if misses > 4 { var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100)) ctx.stroke(a, with: .color(.primary), lineWidth: 4) } if misses > 5 { var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140)) ctx.stroke(a, with: .color(.primary), lineWidth: 4) } } .frame(height: 160) HStack(spacing: 6) { ForEach(Array(cur.enumerated()), id: \.offset) { _, ch in let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame } let show = idx.map { guessed[$0] } ?? false Text(show ? String(ch) : "_") .font(.system(size: 26, weight: .bold, design: .rounded)) .monospaced() } } .frame(maxWidth: .infinity) .padding(.vertical, 8) FlowLayout(spacing: 6) { ForEach(letters, id: \.self) { letter in if let i = letters.firstIndex(of: letter) { Button { guessed[i] = true if !cur.localizedCaseInsensitiveContains(letter) { misses += 1 if misses >= 6 { fb = (vm.fmt("hm_fail", cur), "err") } } else if cur.allSatisfy({ ch in let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame } return idx.map { guessed[$0] } ?? false }) { if word + 1 >= words.count { finished = true fb = (vm.tr("hm_done"), "ok") onDone() } else { fb = (vm.tr("hm_wrong"), "ok") word += 1 misses = 0 guessed = guessed.map { _ in false } } } } label: { Text(letter) .font(.body.weight(.semibold)) .frame(minWidth: 22) } .buttonStyle(.bordered) .tint(guessed[i] ? .secondary : .primary) .disabled(guessed[i] || finished) } } } FeedbackLine(text: fb.0, kind: fb.1, palette: p) if misses >= 6 && !finished { PillButton(label: vm.tr("hm_retry")) { misses = 0 guessed = guessed.map { _ in false } fb = ("", "") } } } .exerciseContent() } .detailScreen(title, subtitle: sub) } } private extension Array { func chunked(_ size: Int) -> [[Element]] { stride(from: 0, to: count, by: size).map { Array(self[$0.. Void @State private var cards: [(String, String, String)] @State private var index = 0 @State private var done = 0 @State private var value = "" @State private var fb = ("", "") let total: Int init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { self.vm = vm self.title = title self.sub = sub self.spec = spec self.onDone = onDone let list = spec.arr("sections").flatMap { sec in sec.arr("rows").map { (sec.str("header"), $0.str("prompt"), $0.str("answers")) } } _cards = State(initialValue: list) total = list.count } var body: some View { let p = vm.palette ScrollView { VStack(alignment: .leading, spacing: 8) { ProgressView(value: Double(done), total: Double(max(total, 1))) Text(vm.fmt("trans_progress", done, total)).font(.footnote).foregroundStyle(.secondary) if cards.isEmpty { Label(vm.tr("trans_done"), systemImage: "checkmark.seal.fill") .font(.headline) .foregroundStyle(p.success) .padding(.top, 8) } else if index < cards.count { let cur = cards[index] GlassCard { VStack(alignment: .leading, spacing: 6) { Text(cur.0).font(.caption.weight(.semibold)).foregroundStyle(.secondary) Text(cur.1).font(.title2.weight(.semibold)) } } .padding(.vertical, 8) if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) } WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p) .submitLabel(.done) .onSubmit { check(cur) } FeedbackLine(text: fb.0, kind: fb.1, palette: p) } } .exerciseContent() } .scrollDismissesKeyboard(.interactively) .detailScreen(title, subtitle: sub) .safeAreaInset(edge: .bottom) { if index < cards.count { BottomAction(label: vm.tr("check"), action: { check(cards[index]) }) { EmptyView() } } } } private func check(_ cur: (String, String, String)) { let norm = normalizeAnswer(value) if answerAccepts(norm, cur.2) { done += 1 cards.remove(at: index) if cards.isEmpty { fb = (vm.tr("trans_done"), "ok") onDone() } else { if index >= cards.count { index = 0 } value = "" fb = ("", "") } } else if answerIncomplete(norm, cur.2) { fb = (vm.tr("trans_incomplete"), "warn") } else { fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err") let item = cards.remove(at: index) cards.append(item) if index >= cards.count { index = 0 } value = "" } } }