Add a SwiftUI iOS app that plays the same maturita content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-18 14:01:01 +02:00
commit 6c7336c7bb
33 changed files with 4498 additions and 10 deletions

View file

@ -0,0 +1,276 @@
import SwiftUI
struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
arrange(width: proposal.width ?? .infinity, subviews: subviews).size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrange(width: bounds.width, subviews: subviews)
for (i, frame) in result.frames.enumerated() {
subviews[i].place(
at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY),
proposal: ProposedViewSize(frame.size)
)
}
}
private func arrange(width: CGFloat, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) {
var frames: [CGRect] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowH: CGFloat = 0
var maxW: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x > 0 && x + size.width > width {
x = 0
y += rowH + spacing
rowH = 0
}
frames.append(CGRect(origin: CGPoint(x: x, y: y), size: size))
x += size.width + spacing
rowH = max(rowH, size.height)
maxW = max(maxW, x - spacing)
}
return (CGSize(width: max(maxW, 0), height: y + rowH), frames)
}
}
struct PrimaryButton: View {
let label: String
let palette: Palette
var action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(palette.onAccent)
.padding(.horizontal, 22)
.padding(.vertical, 12)
.background(palette.accent, in: Capsule())
}
.buttonStyle(.plain)
}
}
struct PillButton: View {
let label: String
let palette: Palette
var action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(palette.text)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(palette.text.opacity(0.06), in: Capsule())
}
.buttonStyle(.plain)
}
}
struct IconHit<Content: View>: View {
var action: () -> Void
@ViewBuilder var content: () -> Content
var body: some View {
Button(action: action) {
content()
.frame(width: 40, height: 40)
}
.buttonStyle(.plain)
}
}
struct PageTop: View {
@ObservedObject var vm: AppModel
var back: () -> Void
let title: String
var subtitle: String?
var body: some View {
let p = vm.palette
VStack(spacing: 6) {
ZStack {
Text(title)
.font(.system(size: 22, weight: .medium))
.foregroundStyle(p.text)
.multilineTextAlignment(.center)
.padding(.horizontal, 48)
HStack {
IconHit(action: back) { BackIcon(color: p.text) }
Spacer()
}
}
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.system(size: 14))
.foregroundStyle(p.subtext)
.multilineTextAlignment(.center)
}
}
.padding(.top, 4)
.padding(.bottom, 14)
}
}
struct CardBox<Content: View>: View {
let palette: Palette
@ViewBuilder var content: () -> Content
var body: some View {
content()
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(palette.text.opacity(0.07), lineWidth: 1)
)
}
}
struct FeedbackLine: View {
let text: String
let kind: String
let palette: Palette
var body: some View {
if !text.isEmpty {
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
.padding(.top, 8)
}
}
}
struct Meaning: View {
let text: String
let palette: Palette
let visible: Bool
var body: some View {
if visible && !text.isEmpty {
Text(text)
.font(.system(size: 13))
.foregroundStyle(palette.subtext)
.padding(.bottom, 8)
}
}
}
struct WordField: View {
@Binding var value: String
let palette: Palette
var placeholder: String
var mark: Bool?
var body: some View {
let border: Color = {
if mark == true { return palette.success }
if mark == false { return palette.error }
return palette.text.opacity(0.12)
}()
ZStack(alignment: .leading) {
if value.isEmpty {
Text(placeholder).foregroundStyle(palette.overlay).font(.system(size: 15))
}
TextField("", text: $value)
.font(.system(size: 15))
.foregroundStyle(palette.text)
.tint(palette.accent)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(palette.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1))
}
}
struct SegChip: View {
let label: String
let selected: Bool
let palette: Palette
var action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.text)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(selected ? palette.mantle : Color.clear, in: Capsule())
}
.buttonStyle(.plain)
}
}
struct Hint: View {
let text: String
let palette: Palette
var body: some View {
Text(text).font(.system(size: 13)).foregroundStyle(palette.subtext).padding(.bottom, 8)
}
}
struct Prompt: View {
let text: String
let palette: Palette
var body: some View {
Text(text).font(.system(size: 16, weight: .medium)).foregroundStyle(palette.text)
}
}
struct WordChip: View {
let text: String
let palette: Palette
var action: () -> Void
var body: some View {
Button(action: action) {
Text(text)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(palette.mantle, in: Capsule())
.overlay(Capsule().stroke(palette.text.opacity(0.1), lineWidth: 1))
}
.buttonStyle(.plain)
}
}
struct OptionChip: View {
let text: String
let selected: Bool
let mark: Bool?
let palette: Palette
var action: () -> Void
var body: some View {
let border: Color = {
if mark == true { return palette.success }
if mark == false { return palette.error }
return selected ? palette.accent : palette.text.opacity(0.1)
}()
Button(action: action) {
Text(text)
.font(.system(size: 15))
.foregroundStyle(palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(selected ? palette.surface1 : palette.mantle, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1))
}
.buttonStyle(.plain)
.padding(.bottom, 6)
}
}

View file

@ -0,0 +1,608 @@
import SwiftUI
struct ExerciseScaffold<Content: View>: 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
VStack(alignment: .leading, spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
ScrollView {
VStack(alignment: .leading, spacing: 0) { content() }
}
FeedbackLine(text: feedback, kind: kind, palette: p)
PrimaryButton(label: action, palette: p, action: onAction)
.padding(.vertical, 12)
}
.padding(.horizontal, 16)
}
}
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(vm.palette.text)
}
}
}
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(.system(size: 16, weight: .semibold))
.foregroundStyle(p.text)
.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, palette: p) { 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..<item.strs("words").count).shuffled()
})
}
var body: some View {
let items = spec.arr("items")
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 { i in
let words = items[i].strs("words")
return placed[i].count == words.count && placed[i].map { words[$0] } == words
}
if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_sentences"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
ForEach(items.indices, id: \.self) { i in
if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt, palette: p) }
FlowLayout {
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) }
ForEach(placed[i], id: \.self) { idx in
if idx < items[i].strs("words").count {
WordChip(text: items[i].strs("words")[idx], palette: p) {
placed[i].removeAll { $0 == idx }
pools[i].append(idx)
}
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.bottom, 6)
FlowLayout(spacing: 6) {
ForEach(pools[i], id: \.self) { idx in
if idx < items[i].strs("words").count {
WordChip(text: items[i].strs("words")[idx], palette: p) {
pools[i].removeAll { $0 == idx }
placed[i].append(idx)
}
}
}
}
if revealed, i < meanings.count {
Meaning(text: vm.tr(meanings[i]), palette: p, visible: true)
}
Spacer().frame(height: 12)
}
}
}
}
private struct TypedEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> 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, palette: p) }
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: p) }
if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)", palette: p) }
if umlaut { Hint(text: vm.tr("hint_umlauts"), palette: p) }
ForEach(items.indices, id: \.self) { i in
let q = items[i]
Prompt(text: q.str("prompt"), palette: p)
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 }),
palette: p,
placeholder: j < hints.count ? hints[j] : vm.tr("your_answer"),
mark: revealed ? answerAccepts(normalizeAnswer(fields[i][j]), ans) : nil
)
.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), palette: p) }
ForEach(qs.indices, id: \.self) { i in
let q = qs[i]
Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question"), palette: p)
WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), palette: p, placeholder: vm.tr("your_answer"))
PillButton(label: vm.tr("show_sample"), palette: p) { 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"), palette: p)
FlowLayout(spacing: 8) {
ForEach(groups.indices, id: \.self) { i in
SegChip(label: groups[i], selected: active == i, palette: p) { active = i }
}
}
Text(vm.tr("wordbank")).font(.system(size: 12)).foregroundStyle(p.subtext).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, palette: p) { loc[i] = active }
}
}
}
ForEach(groups.indices, id: \.self) { g in
Text(groups[g]).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).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, palette: p) { 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() : ""
VStack(alignment: .leading, spacing: 8) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(p.subtext)
if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))", palette: p) }
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(p.text), 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(p.text), lineWidth: 4)
}
if misses > 1 {
ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(p.text), 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(p.text), 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(p.text), 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(p.text), 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(p.text), 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))
.foregroundStyle(p.text)
}
}
ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in
HStack(spacing: 4) {
ForEach(row, 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(.system(size: 15, weight: .bold))
.foregroundStyle(p.text)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(guessed[i] ? p.surface1 : p.mantle, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(guessed[i] || finished)
}
}
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
if misses >= 6 && !finished {
PillButton(label: vm.tr("hm_retry"), palette: p) {
misses = 0
guessed = guessed.map { _ in false }
fb = ("", "")
}
}
Spacer()
}
.padding(.horizontal, 16)
}
}
private extension Array {
func chunked(_ size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
}
}
private struct VocabEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> 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
VStack(alignment: .leading, spacing: 8) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
Text(vm.fmt("trans_progress", done, total)).foregroundStyle(p.subtext)
if cards.isEmpty {
Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success)
} else if index < cards.count {
let cur = cards[index]
Hint(text: cur.0, palette: p)
Prompt(text: cur.1, palette: p)
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts"), palette: p) }
WordField(value: $value, palette: p, placeholder: vm.tr("your_answer"))
PrimaryButton(label: vm.tr("check"), palette: p) {
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 = ""
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
}
Spacer()
}
.padding(.horizontal, 16)
}
}

View file

@ -0,0 +1,857 @@
import SwiftUI
struct DialogEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
var body: some View {
let pool = spec.strs("pool")
let meanings = spec.strs("meanings")
let lines = dialogLines()
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: lines.count, onDone: onDone) { picks, expected, revealed in
ForEach(lines.indices, id: \.self) { i in
let line = lines[i]
if let name = line.name {
Text(name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(vm.palette.subtext)
.padding(.vertical, 6)
}
ComboLine(
vm: vm, pool: pool, picks: picks, expected: expected, idx: i,
answer: line.answer,
prefix: line.prefix,
suffix: line.suffix,
meaning: i < meanings.count ? meanings[i] : nil,
revealed: revealed
)
}
}
}
private func dialogLines() -> [(name: String?, prefix: String, answer: String, suffix: String)] {
var out: [(name: String?, prefix: String, answer: String, suffix: String)] = []
for d in spec.arr("dialogues") {
var header: String? = d.str("name")
for row in d.arr("rows") {
out.append((
header,
[row.str("speaker"), row.str("before")].filter { !$0.isEmpty }.joined(separator: " "),
row.str("answer"),
row.str("after")
))
header = nil
}
}
return out
}
}
struct ComboEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
var body: some View {
let pool = spec.strs("pool")
let rows = spec.arr("rows")
let meanings = spec.strs("meanings")
let type = spec.str("type")
if type == "verb_sections" {
let sections = spec.arr("sections")
let allRows = sections.flatMap { $0.arr("rows") }
ComboRows(vm: vm, title: title, sub: sub, pool: spec.strs("pool"), n: allRows.count, onDone: onDone, revealMean: spec.bool("transUpfront")) { picks, expected, revealed in
ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in
if let title = line.title {
Text(title).font(.system(size: 15, weight: .semibold)).foregroundStyle(vm.palette.subtext)
}
ComboLine(
vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx,
answer: line.answer, prefix: line.before, suffix: line.after,
meaning: line.meaning,
revealed: revealed || spec.bool("transUpfront")
)
}
}
} else if type == "ordne" {
ComboRows(vm: vm, title: title, sub: sub, pool: [], n: rows.count * 2, onDone: onDone) { picks, expected, revealed in
ForEach(rows.indices, id: \.self) { i in
ComboLine(vm: vm, pool: spec.strs("fwPool"), picks: picks, expected: expected, idx: i * 2, answer: rows[i].str("fw"), prefix: "\(i + 1). ", suffix: rows[i].str("mid"), revealed: revealed)
ComboLine(vm: vm, pool: spec.strs("ansPool"), picks: picks, expected: expected, idx: i * 2 + 1, answer: rows[i].str("ans"), prefix: "→ ", meaning: i < meanings.count ? meanings[i] : nil, revealed: revealed)
}
}
} else if type == "fill" {
let blanks = rows.reduce(0) { $0 + $1.answerList().count }
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) }
ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
FlowLayout(spacing: 4) {
if let num = block.num {
Text(num).font(.system(size: 16, weight: .semibold)).foregroundStyle(vm.palette.text)
}
ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(vm.palette.text)
case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
}
}
if let mean = block.meaning {
Text(vm.tr(mean)).font(.system(size: 12)).foregroundStyle(vm.palette.subtext)
}
}
.padding(.bottom, 6)
if revealed || spec.bool("preMeaning") || spec.bool("showGerman"), let mean = block.rowMean {
Meaning(text: vm.tr(mean), palette: vm.palette, visible: true)
}
}
}
} else {
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: rows.count, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) }
ForEach(rows.indices, id: \.self) { i in
let r = rows[i]
let prefix: String = {
switch type {
case "number": return "\(r.str("digits")) = "
case "count": return "\(Array(repeating: r.str("emoji"), count: r.int("count")).joined(separator: " ")) \(r.str("noun")) "
case "seq": return r.str("before")
case "verbclue": return "\(r.str("emoji")) \(r.str("before"))"
default: return r.str("before")
}
}()
let suffix: String = {
switch type {
case "seq", "verbclue", "verb": return r.str("after")
default: return ""
}
}()
ComboLine(
vm: vm, pool: pool, picks: picks, expected: expected, idx: i, answer: r.str("answer"),
prefix: prefix, suffix: suffix,
meaning: i < meanings.count ? meanings[i] : r.strOrNull("mean"),
revealed: revealed || spec.bool("preMeaning")
)
}
}
}
}
private func verbSectionLines() -> [(title: String?, before: String, answer: String, after: String, meaning: String?)] {
var out: [(title: String?, before: String, answer: String, after: String, meaning: String?)] = []
for sec in spec.arr("sections") {
var header: String? = sec.str("title")
let meanings = sec.strs("meanings")
for (ri, row) in sec.arr("rows").enumerated() {
out.append((
header,
row.str("before"),
row.str("answer"),
row.str("after"),
ri < meanings.count ? meanings[ri] : nil
))
header = nil
}
}
return out
}
private enum FillPart {
case text(String)
case blank(Int, String)
}
private func fillRows() -> [(num: String?, parts: [FillPart], meaning: String?, rowMean: String?)] {
let meanings = spec.strs("meanings")
var i = 0
return spec.arr("rows").enumerated().map { ri, r in
var parts: [FillPart] = []
let segs = r.strsOrEmpty("segs")
let ans = r.answerList()
for si in segs.indices {
if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
if si < ans.count {
parts.append(.blank(i, ans[si]))
i += 1
}
}
return (
r.strOrNull("num"),
parts,
ri < meanings.count ? meanings[ri] : nil,
r.strOrNull("mean")
)
}
}
}
private struct ComboRows<Body: View>: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let pool: [String]
let n: Int
var onDone: () -> Void
var revealMean = false
@ViewBuilder var bodyContent: (Binding<[String]>, Binding<[String]>, Bool) -> Body
@State private var picks: [String]
@State private var expected: [String]
@State private var fb = ("", "")
@State private var revealed: Bool
init(
vm: AppModel,
title: String,
sub: String?,
pool: [String],
n: Int,
onDone: @escaping () -> Void,
revealMean: Bool = false,
@ViewBuilder bodyContent: @escaping (Binding<[String]>, Binding<[String]>, Bool) -> Body
) {
self.vm = vm
self.title = title
self.sub = sub
self.pool = pool
self.n = n
self.onDone = onDone
self.revealMean = revealMean
self.bodyContent = bodyContent
_picks = State(initialValue: Array(repeating: "", count: n))
_expected = State(initialValue: Array(repeating: "", count: n))
_revealed = State(initialValue: revealMean)
}
var body: some View {
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
let ok = expected.indices.allSatisfy { i in
let exp = expected[i]
return exp.isEmpty || answerAccepts(normalizeAnswer(picks[i]), exp)
}
if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_retry"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
bodyContent($picks, $expected, revealed)
}
}
}
private struct ComboLine: View {
@ObservedObject var vm: AppModel
let pool: [String]
@Binding var picks: [String]
@Binding var expected: [String]
let idx: Int
let answer: String
var prefix: String = ""
var suffix: String = ""
var meaning: String?
var revealed: Bool
var inline = false
@State private var open = false
var body: some View {
let p = vm.palette
let pick = idx < picks.count ? picks[idx] : ""
let mark: Bool? = {
if revealed && !pick.isEmpty { return answerAccepts(normalizeAnswer(pick), answer) }
return nil
}()
let chip = Button {
open = true
} label: {
Text(pick.isEmpty ? "—" : pick)
.font(.system(size: 15))
.foregroundStyle(pick.isEmpty ? p.overlay : p.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(p.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(mark == true ? p.success : mark == false ? p.error : p.text.opacity(0.12), lineWidth: 1)
)
}
.buttonStyle(.plain)
Group {
if inline {
chip
} else {
FlowLayout(spacing: 6) {
if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) }
chip
if !suffix.isEmpty { Text(suffix).font(.system(size: 16)).foregroundStyle(p.text) }
}
.padding(.bottom, 8)
}
}
.onAppear {
if idx >= 0 && idx < expected.count { expected[idx] = answer }
}
.onChange(of: answer) { new in
if idx >= 0 && idx < expected.count { expected[idx] = new }
}
.sheet(isPresented: $open) {
WordPicker(palette: p, pool: pool) { word in
if let word, idx < picks.count { picks[idx] = word }
open = false
}
.presentationDetents([.medium, .large])
}
if revealed, let meaning, !meaning.isEmpty {
Meaning(text: vm.tr(meaning), palette: p, visible: true)
}
}
}
struct WordPicker: View {
let palette: Palette
let pool: [String]
var onPick: (String?) -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(pool, id: \.self) { w in
Button { onPick(w) } label: {
Text(w)
.font(.system(size: 16))
.foregroundStyle(palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
}
.padding(10)
}
.background(palette.base)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(role: .cancel) { onPick(nil) } label: { Text("Zavřít") }
}
}
}
}
}
struct Ex2Ex: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
var body: some View {
let items = spec.arr("items")
let pool = spec.strs("pool")
let blanks = items.reduce(0) { acc, item in
acc + item.arr("rows").reduce(0) { $0 + $1.strs("answers").count }
}
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone) { picks, expected, revealed in
ForEach(Array(ex2Blocks().enumerated()), id: \.offset) { _, block in
Meaning(text: block.czech, palette: vm.palette, visible: true)
ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in
FlowLayout(spacing: 4) {
if let num = row.num {
Text(num).foregroundStyle(vm.palette.text).padding(.trailing, 6)
}
ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(vm.palette.text)
case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
}
}
}
}
}
}
}
private enum Part {
case text(String)
case blank(Int, String)
}
private func ex2Blocks() -> [(czech: String, rows: [(num: String?, parts: [Part])])] {
var i = 0
return spec.arr("items").map { item in
let rows = item.arr("rows").map { row -> (num: String?, parts: [Part]) in
var parts: [Part] = []
let segs = row.strsOrEmpty("segs")
let ans = row.strs("answers")
for si in segs.indices {
if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
if si < ans.count {
parts.append(.blank(i, ans[si]))
i += 1
}
}
return (row.strOrNull("num"), parts)
}
return (item.str("czech"), rows)
}
}
}
struct LettersEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> 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 n = spec.arr("rows").flatMap { $0.strs("answers") }.count
_fields = State(initialValue: Array(repeating: "", count: n))
}
var body: some View {
let rows = spec.arr("rows")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
var i = 0, ok = 0, total = 0
for r in rows {
for ans in r.strs("answers") {
total += 1
if i < fields.count && answerAccepts(normalizeAnswer(fields[i]), ans) { ok += 1 }
i += 1
}
}
if ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_retry"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
if rows.contains(where: { $0.strs("answers").contains { hasUmlaut($0) } }) {
Hint(text: vm.tr("hint_umlauts"), palette: p)
}
LettersRows(vm: vm, rows: rows, fields: $fields, revealed: revealed)
}
}
}
private struct LettersRows: View {
@ObservedObject var vm: AppModel
let rows: [J]
@Binding var fields: [String]
let revealed: Bool
var body: some View {
let p = vm.palette
let pairs = build()
VStack(alignment: .leading, spacing: 8) {
ForEach(pairs.indices, id: \.self) { ri in
let row = pairs[ri]
if let num = row.num { Text(num).foregroundStyle(p.subtext) }
FlowLayout {
ForEach(row.parts.indices, id: \.self) { pi in
switch row.parts[pi] {
case .text(let s):
Text(s).foregroundStyle(p.text)
case .field(let idx, let ans):
WordField(
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
palette: p,
placeholder: "",
mark: revealed ? answerAccepts(normalizeAnswer(idx < fields.count ? fields[idx] : ""), ans) : nil
)
.frame(width: 72)
}
}
}
if revealed, let czech = row.czech { Meaning(text: czech, palette: p, visible: true) }
if revealed, let mean = row.mean { Meaning(text: mean, palette: p, visible: true) }
}
}
}
private enum Part {
case text(String)
case field(Int, String)
}
private struct RowModel {
var num: String?
var parts: [Part]
var czech: String?
var mean: String?
}
private func build() -> [RowModel] {
var i = 0
return rows.map { r in
var parts: [Part] = []
let segs = r.strsOrEmpty("segs")
let ans = r.strs("answers")
for si in segs.indices {
if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
if si < ans.count {
parts.append(.field(i, ans[si]))
i += 1
}
}
return RowModel(num: r.strOrNull("num"), parts: parts, czech: r.strOrNull("czech"), mean: r.strOrNull("mean"))
}
}
}
struct TableEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> 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
_fields = State(initialValue: Array(repeating: "", count: spec.strs("nouns").count * spec.strs("persons").count))
}
var body: some View {
let persons = spec.strs("persons")
let nouns = spec.strs("nouns")
let answers = spec.intRows("answers")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
var ok = 0
for (r, row) in answers.enumerated() {
for (c, ans) in row.enumerated() {
let i = r * persons.count + c
if i < fields.count && normalizeAnswer(fields[i]) == normalizeAnswer(ans) { ok += 1 }
}
}
if ok == fields.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_retry"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
ForEach(nouns.indices, id: \.self) { r in
Text(nouns[r]).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
ForEach(persons.indices, id: \.self) { c in
let i = r * persons.count + c
Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext)
WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), palette: p, placeholder: "")
}
Spacer().frame(height: 8)
}
}
}
}
struct RevealEx: 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 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
_shown = State(initialValue: Array(repeating: false, count: spec.arr("items").count))
}
var body: some View {
let items = spec.arr("items")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: {
shown = shown.map { _ in true }
fb = (vm.tr("feedback_ok"), "ok")
onDone()
}, feedback: fb.0, kind: fb.1) {
if let note = spec.strOrNull("note") { Hint(text: note, palette: p) }
ForEach(items.indices, id: \.self) { i in
Prompt(text: items[i].str("prompt"), palette: p)
PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() }
.padding(.vertical, 6)
if shown[i] { Meaning(text: items[i].str("solution"), palette: p, visible: true) }
Spacer().frame(height: 8)
}
}
}
}
struct NetQuizScreen: View {
@ObservedObject var vm: AppModel
let id: Int
var body: some View {
if let lesson = vm.content.netLesson(id) {
if id == 1 {
VlsmEx(vm: vm, lesson: lesson)
} else {
let spec = J([
"type": "choice",
"title": vm.tr(lesson.str("exTitleKey")),
"sub": "net_quiz_intro",
"questions": lesson.rawArray("quiz"),
])
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
vm.markNet(id)
}
}
}
}
}
struct HwQuizScreen: View {
@ObservedObject var vm: AppModel
let id: Int
var body: some View {
if let lesson = vm.content.hwLesson(id) {
let spec = J([
"type": "choice",
"title": vm.tr(lesson.str("exTitleKey")),
"sub": lesson.str("quizHeadKey"),
"questions": lesson.rawArray("quiz"),
])
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
vm.markHw(id)
}
}
}
}
private struct VlsmRow {
var pfx = ""
var net = ""
var bc = ""
var lo = ""
var hi = ""
}
private struct VlsmEx: View {
@ObservedObject var vm: AppModel
let lesson: J
@State private var done: [Bool]
@State private var rows: [[VlsmRow]]
@State private var fb = ("", "")
init(vm: AppModel, lesson: J) {
self.vm = vm
self.lesson = lesson
let answers = vm.content.netAnswers
_done = State(initialValue: Array(repeating: false, count: vm.content.netTasks.count))
_rows = State(initialValue: answers.map { block in Array(repeating: VlsmRow(), count: block.count) })
}
var body: some View {
let tasks = vm.content.netTasks
let answers = vm.content.netAnswers
let p = vm.palette
VStack(alignment: .leading, spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_ex_title"), subtitle: nil)
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(tasks.indices, id: \.self) { ti in
let task = tasks[ti]
let block = ti < answers.count ? answers[ti] : []
let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 }
Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8)
if infeasible {
Hint(text: vm.tr("net_infeasible"), palette: p)
} else {
ForEach(block.indices, id: \.self) { si in
Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(p.subtext)
PrefixMenu(value: Binding(
get: { rows[ti][si].pfx },
set: { rows[ti][si].pfx = $0 }
), palette: p)
WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), palette: p, placeholder: "síť")
WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), palette: p, placeholder: "broadcast")
WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), palette: p, placeholder: "první uzel")
WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), palette: p, placeholder: "poslední uzel")
}
PillButton(label: vm.tr("check"), palette: p) {
let ok = block.indices.allSatisfy { si in
let a = block[si]
let field = rows[ti][si]
return Int(field.pfx.replacingOccurrences(of: "/", with: "")) == a.int("pfx")
&& netTxtEq(field.net, a.str("net"))
&& netTxtEq(field.bc, a.str("bcast"))
&& netTxtEq(field.lo, a.str("lo"))
&& netTxtEq(field.hi, a.str("hi"))
}
if ok {
done[ti] = true
fb = (vm.tr("feedback_ok"), "ok")
maybeFinish()
} else {
fb = (vm.tr("feedback_retry"), "err")
}
}
.padding(.vertical, 6)
}
PillButton(label: vm.tr("net_solution"), palette: p) {
if !infeasible {
for si in block.indices {
rows[ti][si].pfx = "/\(block[si].int("pfx"))"
rows[ti][si].net = block[si].str("net")
rows[ti][si].bc = block[si].str("bcast")
rows[ti][si].lo = block[si].str("lo")
rows[ti][si].hi = block[si].str("hi")
}
}
done[ti] = true
fb = ("", "")
maybeFinish()
}
if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) }
}
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
}
.padding(.horizontal, 16)
}
private func maybeFinish() {
if !done.isEmpty && done.allSatisfy({ $0 }) { vm.markNet(1) }
}
}
private struct PrefixMenu: View {
@Binding var value: String
let palette: Palette
@State private var open = false
var body: some View {
Button { open = true } label: {
Text(value.isEmpty ? "/…" : value)
.font(.system(size: 15))
.foregroundStyle(palette.text)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.padding(.vertical, 4)
.sheet(isPresented: $open) {
WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
if let word { value = word }
open = false
}
.presentationDetents([.medium, .large])
}
}
}
struct LitQuizScreen: View {
@ObservedObject var vm: AppModel
let id: String
@State private var idx = 0
@State private var score = 0
@State private var answered = false
@State private var finished = false
var body: some View {
if let book = vm.content.book(id) {
let qs = book.arr("quiz")
let p = vm.palette
let kahoot = [p.accent, p.error, p.warning, p.success]
VStack(alignment: .leading, spacing: 8) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
if finished {
Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext)
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
PrimaryButton(label: vm.tr("lit_again"), palette: p) {
idx = 0; score = 0; answered = false; finished = false
}
} else if idx < qs.count {
let q = qs[idx]
HStack {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)).foregroundStyle(p.subtext)
Spacer()
Text(vm.fmt("lit_score_fmt", score, qs.count)).foregroundStyle(p.subtext)
}
Text(q.str("prompt")).font(.system(size: 18, weight: .semibold)).foregroundStyle(p.text).padding(.vertical, 12)
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
Button {
answered = true
if i == q.int("correct") { score += 1 }
} label: {
Text(opt)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.onAccent)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(kahoot[i % 4], in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
.disabled(answered)
.padding(.bottom, 8)
}
if answered {
Meaning(text: q.str("expl"), palette: p, visible: true)
PrimaryButton(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), palette: p) {
if idx + 1 >= qs.count {
finished = true
vm.markBookQuiz(id)
} else {
idx += 1
answered = false
}
}
}
}
Spacer()
}
.padding(16)
}
}
}
struct PlotScreen: View {
@ObservedObject var vm: AppModel
let id: String
var body: some View {
if let book = vm.content.book(id) {
let spec = J([
"type": "assembly",
"title": vm.tr(book.str("plotTitle")),
"sub": book.str("plotSub"),
"items": book.rawArray("plot"),
"meanings": book.rawArray("plotMeaning"),
])
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(book.str("plotTitle"))) {
vm.markBookPlot(id)
}
}
}
}

304
ios/Maturita/UI/Icons.swift Normal file
View file

@ -0,0 +1,304 @@
import SwiftUI
struct BackIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.18))
p.addLine(to: CGPoint(x: sz.width * 0.28, y: sz.height * 0.5))
p.addLine(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.82))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct SearchIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.28
let c = CGPoint(x: sz.width * 0.42, y: sz.height * 0.42)
ctx.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.12)
var handle = Path()
handle.move(to: CGPoint(x: sz.width * 0.62, y: sz.height * 0.62))
handle.addLine(to: CGPoint(x: sz.width * 0.82, y: sz.height * 0.82))
ctx.stroke(handle, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.12, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct StatsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let w = min(sz.width, sz.height) * 0.16
let bars: [(CGFloat, CGFloat, CGFloat)] = [
(0.18, 0.48, 0.36), (0.42, 0.22, 0.62), (0.66, 0.36, 0.48),
]
for b in bars {
let rect = CGRect(x: sz.width * b.0, y: sz.height * b.1, width: w, height: sz.height * b.2)
ctx.fill(Path(roundedRect: rect, cornerRadius: w), with: .color(color))
}
}
.frame(width: size, height: size)
}
}
struct SettingsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let cx = sz.width / 2
let cy = sz.height / 2
let s = min(sz.width, sz.height)
let rOuter = s * 0.48
let rInner = s * 0.34
let rHole = s * 0.16
let teeth = 8
let toothHalf = CGFloat.pi / CGFloat(teeth)
var gear = Path()
for i in 0..<teeth {
let a = -CGFloat.pi / 2 + 2 * CGFloat.pi * CGFloat(i) / CGFloat(teeth)
let a0 = a - toothHalf * 0.55
let a1 = a - toothHalf * 0.28
let a2 = a + toothHalf * 0.28
let a3 = a + toothHalf * 0.55
let p0 = CGPoint(x: cx + cos(a0) * rInner, y: cy + sin(a0) * rInner)
if i == 0 { gear.move(to: p0) } else { gear.addLine(to: p0) }
gear.addLine(to: CGPoint(x: cx + cos(a1) * rOuter, y: cy + sin(a1) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a2) * rOuter, y: cy + sin(a2) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a3) * rInner, y: cy + sin(a3) * rInner))
}
gear.closeSubpath()
gear.addEllipse(in: CGRect(x: cx - rHole, y: cy - rHole, width: rHole * 2, height: rHole * 2))
ctx.fill(gear, with: .color(color), style: FillStyle(eoFill: true))
}
.frame(width: size, height: size)
}
}
struct LockIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let body = CGRect(x: sz.width * 0.28, y: sz.height * 0.46, width: sz.width * 0.44, height: sz.height * 0.36)
ctx.fill(Path(roundedRect: body, cornerRadius: min(sz.width, sz.height) * 0.08), with: .color(color))
var arc = Path()
arc.addArc(
center: CGPoint(x: sz.width * 0.5, y: sz.height * 0.38),
radius: sz.width * 0.18,
startAngle: .degrees(200),
endAngle: .degrees(340),
clockwise: false
)
ctx.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.1, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct CheckIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.18, y: sz.height * 0.52))
p.addLine(to: CGPoint(x: sz.width * 0.4, y: sz.height * 0.74))
p.addLine(to: CGPoint(x: sz.width * 0.84, y: sz.height * 0.26))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct WifiIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let c = CGPoint(x: sz.width / 2, y: sz.height * 0.72)
ctx.fill(Path(ellipseIn: CGRect(x: c.x - sz.width * 0.06, y: c.y - sz.width * 0.06, width: sz.width * 0.12, height: sz.width * 0.12)), with: .color(color))
for i in 1...3 {
let r = min(sz.width, sz.height) * (0.16 + CGFloat(i) * 0.16)
var p = Path()
p.addArc(center: c, radius: r, startAngle: .degrees(210), endAngle: .degrees(330), clockwise: false)
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct ChipIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let m = min(sz.width, sz.height) * 0.22
ctx.stroke(
Path(roundedRect: CGRect(x: m, y: m, width: sz.width - 2 * m, height: sz.height - 2 * m), cornerRadius: 6),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
let pin = min(sz.width, sz.height) * 0.08
for i in 0..<3 {
let t = m + (sz.height - 2 * m) * CGFloat(i + 1) / 4
var l = Path(); l.move(to: CGPoint(x: m - pin, y: t)); l.addLine(to: CGPoint(x: m, y: t))
var r = Path(); r.move(to: CGPoint(x: sz.width - m, y: t)); r.addLine(to: CGPoint(x: sz.width - m + pin, y: t))
ctx.stroke(l, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
ctx.stroke(r, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
}
.frame(width: size, height: size)
}
}
struct GermanFlag: View {
var size: CGFloat = 48
var body: some View {
Canvas { ctx, sz in
let band = sz.height / 3
ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
ctx.fill(Path(CGRect(x: 0, y: 0, width: sz.width, height: band)), with: .color(Color(red: 0.18, green: 0.18, blue: 0.19)))
ctx.fill(Path(CGRect(x: 0, y: band, width: sz.width, height: band)), with: .color(Color(red: 0.72, green: 0.27, blue: 0.24)))
ctx.fill(Path(CGRect(x: 0, y: 2 * band, width: sz.width, height: band)), with: .color(Color(red: 0.84, green: 0.70, blue: 0.32)))
}
.frame(width: size, height: size)
}
}
struct CzechFlag: View {
var size: CGFloat = 48
var body: some View {
Canvas { ctx, sz in
ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
ctx.fill(Path(CGRect(origin: .zero, size: sz)), with: .color(Color(white: 0.98)))
ctx.fill(Path(CGRect(x: 0, y: sz.height / 2, width: sz.width, height: sz.height / 2)), with: .color(Color(red: 0.83, green: 0.16, blue: 0.18)))
var tri = Path()
tri.move(to: .zero)
tri.addLine(to: CGPoint(x: sz.width * 0.55, y: sz.height / 2))
tri.addLine(to: CGPoint(x: 0, y: sz.height))
tri.closeSubpath()
ctx.fill(tri, with: .color(Color(red: 0.07, green: 0.24, blue: 0.55)))
}
.frame(width: size, height: size)
}
}
struct BookIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
ctx.stroke(
Path(roundedRect: CGRect(x: sz.width * 0.22, y: sz.height * 0.18, width: sz.width * 0.56, height: sz.height * 0.64), cornerRadius: 4),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
var spine = Path()
spine.move(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.18))
spine.addLine(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.82))
ctx.stroke(spine, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
}
.frame(width: size, height: size)
}
}
struct QuizIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.36
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
let d = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width / 2 - d / 2, y: sz.height * 0.68 - d / 2, width: d, height: d)), with: .color(color))
var stem = Path()
stem.move(to: CGPoint(x: sz.width / 2, y: sz.height * 0.32))
stem.addLine(to: CGPoint(x: sz.width / 2, y: sz.height * 0.52))
ctx.stroke(stem, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct OrderIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
for i in 0..<3 {
let y = sz.height * (0.28 + CGFloat(i) * 0.22)
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.22, y: y))
p.addLine(to: CGPoint(x: sz.width * 0.78, y: y))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct GlobeIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.34
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width * 0.32, y: sz.height * 0.18, width: sz.width * 0.36, height: sz.height * 0.64)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
.frame(width: size, height: size)
}
}
struct PeopleIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.35 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.65 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
var a = Path(); a.addArc(center: CGPoint(x: sz.width * 0.35, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
var b = Path(); b.addArc(center: CGPoint(x: sz.width * 0.65, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
ctx.stroke(a, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
ctx.stroke(b, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct BulbIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.22
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height * 0.4 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.fill(Path(roundedRect: CGRect(x: sz.width * 0.4, y: sz.height * 0.58, width: sz.width * 0.2, height: sz.height * 0.2), cornerRadius: 3), with: .color(color))
}
.frame(width: size, height: size)
}
}
@ViewBuilder
func noteIcon(_ name: String, _ color: Color) -> some View {
switch name {
case "globe": GlobeIcon(color: color)
case "people": PeopleIcon(color: color)
case "bulb": BulbIcon(color: color)
case "quiz": QuizIcon(color: color)
case "order": OrderIcon(color: color)
default: BookIcon(color: color)
}
}

View file

@ -0,0 +1,63 @@
import SwiftUI
struct MapNode {
let id: String
let label: String
let locked: Bool
let done: Bool
let current: Bool
var finish: Bool = false
var onClick: (() -> Void)?
}
struct PathMap: View {
let nodes: [MapNode]
let palette: Palette
var body: some View {
ScrollView {
LazyVStack(spacing: 10) {
ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in
Button {
if !node.locked { node.onClick?() }
} label: {
CardBox(palette: palette) {
HStack(spacing: 12) {
let bg: Color = {
if node.locked { return palette.lockedBg }
if node.current || node.done { return palette.accent }
return palette.node
}()
let fg: Color = {
if node.locked { return palette.overlay }
if node.current || node.done { return palette.onAccent }
return palette.text
}()
ZStack {
Circle().fill(bg).frame(width: 44, height: 44)
if node.locked {
LockIcon(color: palette.overlay, size: 16)
} else if node.done {
CheckIcon(color: palette.onAccent, size: 16)
} else {
Text("\(i + 1)")
.font(.system(size: 16, weight: .heavy))
.foregroundStyle(fg)
}
}
Text(node.label)
.font(.system(size: 16, weight: node.current ? .bold : .semibold))
.foregroundStyle(node.locked ? palette.overlay : palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.buttonStyle(.plain)
.disabled(node.locked || node.onClick == nil)
.opacity(node.locked ? 0.48 : 1)
}
}
.padding(.bottom, 24)
}
}
}

View file

@ -0,0 +1,698 @@
import SwiftUI
struct WelcomeScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("maturita.c")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(p.text)
Text("sestavení \(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
.font(.system(size: 12))
.foregroundStyle(p.overlay)
.padding(.bottom, 12)
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
Text(vm.tr("welcome_title"))
.font(.system(size: 32, weight: .medium))
.foregroundStyle(p.text)
Text(vm.tr("welcome_body_ios"))
.font(.system(size: 16))
.foregroundStyle(p.subtext)
PrimaryButton(label: vm.tr("continue"), palette: p) { vm.go(.subjects) }
}
Spacer(minLength: 0)
VStack {
Text(vm.tr("welcome_stat_value"))
.font(.system(size: 28, weight: .bold))
.foregroundStyle(p.text)
Text(vm.tr("welcome_stat_label"))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(p.subtext)
.multilineTextAlignment(.center)
}
.frame(width: 108, height: 108)
.background(p.mantle, in: Circle())
.overlay(Circle().stroke(p.text.opacity(0.08), lineWidth: 1))
}
FeatureCard(vm: vm, t: "welcome_feat1_title", b: "welcome_feat1_body", l: "welcome_feat1_link")
.padding(.top, 28)
FeatureCard(vm: vm, t: "welcome_feat2_title", b: "welcome_feat2_body", l: "welcome_feat2_link")
.padding(.top, 12)
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
}
}
private struct FeatureCard: View {
@ObservedObject var vm: AppModel
let t, b, l: String
var body: some View {
let p = vm.palette
CardBox(palette: p) {
VStack(alignment: .leading, spacing: 6) {
Text(vm.tr(t)).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr(b)).font(.system(size: 15)).foregroundStyle(p.subtext)
PillButton(label: vm.tr(l), palette: p) { vm.go(.subjects) }
.padding(.top, 4)
}
}
}
}
struct SubjectsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("subjects_title"), subtitle: vm.tr("subjects_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
ForEach(Array(stride(from: 0, to: vm.content.subjects.count, by: 2)), id: \.self) { i in
HStack(spacing: 12) {
subjectTile(vm.content.subjects[i], p)
if i + 1 < vm.content.subjects.count {
subjectTile(vm.content.subjects[i + 1], p)
} else {
Color.clear.frame(maxWidth: .infinity).frame(height: 132)
}
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 24)
}
}
}
private func subjectTile(_ s: J, _ p: Palette) -> some View {
let open = s.bool("open")
return Button {
if open { vm.goPage(s.str("target")) }
} label: {
VStack(spacing: 10) {
switch s.str("icon") {
case "de": GermanFlag(size: 44)
case "wifi": WifiIcon(color: p.text, size: 36)
case "chip": ChipIcon(color: p.text, size: 36)
case "cz": CzechFlag(size: 44)
default: LockIcon(color: p.overlay, size: 22)
}
Text(vm.tr(s.str("key")))
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(p.text)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 132)
.background(p.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(p.text.opacity(open ? 0.08 : 0.04), lineWidth: 1)
)
.opacity(open ? 1 : 0.48)
}
.buttonStyle(.plain)
.disabled(!open)
}
}
struct SettingsSheet: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
ScrollView {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(vm.tr("settings_title"))
.font(.system(size: 22, weight: .bold))
.foregroundStyle(p.text)
Spacer()
PillButton(label: vm.tr("back"), palette: p) { vm.settingsOpen = false }
}
Text(vm.tr("mode")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
HStack(spacing: 4) {
SegChip(label: vm.tr("mode_dark"), selected: vm.mode == .dark, palette: p) { vm.applyMode(.dark) }
SegChip(label: vm.tr("mode_light"), selected: vm.mode == .light, palette: p) { vm.applyMode(.light) }
}
.padding(3)
.background(p.surface1, in: Capsule())
Text(vm.tr("theme")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
ForEach(Array(themeNames.enumerated()), id: \.offset) { i, name in
if let id = ThemeId(rawValue: i) {
let sw = themePalette(id, vm.mode)
let sel = vm.themeId == id
Button { vm.setTheme(id) } label: {
HStack(spacing: 10) {
ZStack {
Circle().fill(sw.base)
Circle().fill(sw.accent).frame(width: 10, height: 10).offset(x: -8)
Circle().fill(sw.accent3).frame(width: 10, height: 10)
Circle().fill(sw.success).frame(width: 10, height: 10).offset(x: 8)
if sel { Circle().stroke(p.accent, lineWidth: 2.2) }
}
.frame(width: 36, height: 36)
Text(name).font(.system(size: 16, weight: .medium)).foregroundStyle(p.text)
Spacer()
}
.padding(10)
.background(sel ? p.surface1 : p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
}
}
Text(vm.tr("language")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
HStack(spacing: 4) {
SegChip(label: "Čeština", selected: vm.lang == .cs, palette: p) { vm.applyLang(.cs) }
SegChip(label: "English", selected: vm.lang == .en, palette: p) { vm.applyLang(.en) }
}
.padding(3)
.background(p.surface1, in: Capsule())
Text(vm.tr("updates")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
Text(updateText)
.font(.system(size: 14))
.foregroundStyle(p.subtext)
HStack(spacing: 8) {
PillButton(label: vm.tr("update_check"), palette: p) { vm.checkUpdate(true) }
if vm.update.canInstall {
PrimaryButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() }
}
}
.padding(.bottom, 24)
}
.padding(20)
}
.background(p.base)
}
private var updateText: String {
if let arg = vm.update.messageArg {
return vm.fmt(vm.update.messageKey, arg)
}
return vm.tr(vm.update.messageKey)
}
}
struct StatsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0 : (100 * sum.doneEx / sum.totalEx)
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("stats_title"), subtitle: vm.tr("stats_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
metric(p, vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
metric(p, vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", pct))
metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
}
ProgressBar(fraction: CGFloat(pct) / 100, palette: p, height: 8)
Text(vm.tr("stats_section"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(p.subtext)
.padding(.top, 10)
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
let open = s.bool("open")
let part = subjectSum(vm, s)
CardBox(palette: p) {
VStack(alignment: .leading, spacing: 4) {
Text(vm.tr(s.str("key"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
.font(.system(size: 13)).foregroundStyle(p.subtext)
if open && part.totalEx > 0 {
ProgressBar(fraction: CGFloat(part.doneEx) / CGFloat(part.totalEx), palette: p, height: 6)
.padding(.top, 4)
}
}
}
.opacity(open ? 1 : 0.55)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 24)
}
}
}
private func metric(_ p: Palette, _ label: String, _ value: String) -> some View {
CardBox(palette: p) {
VStack(alignment: .leading) {
Text(label).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
Text(value).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
}
}
}
}
struct ProgressBar: View {
let fraction: CGFloat
let palette: Palette
var height: CGFloat = 8
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(palette.surface1)
Capsule().fill(palette.accent).frame(width: geo.size.width * min(max(fraction, 0), 1))
}
}
.frame(height: height)
}
}
private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum {
var sum = ProgressSum()
switch s.str("target") {
case "roadmap":
for u in vm.content.german where u.bool("unlocked") {
let n = u.strs("names").count
sum.totalEx += n
sum.doneEx += (1...n).filter { vm.progress.germanDone(u.int("id"), $0) }.count
}
case "netyears":
sum.totalEx = vm.content.netLessons.count
sum.doneEx = vm.content.netLessons.filter { vm.progress.netDone($0.int("id")) }.count
case "hwmap":
sum.totalEx = vm.content.hw.count
sum.doneEx = vm.content.hw.filter { vm.progress.hwDone($0.int("id")) }.count
case "czechmap":
sum.totalEx = vm.content.mluvnice.count
sum.doneEx = vm.content.mluvnice.filter { vm.progress.mluvDone($0.int("id")) }.count
default:
break
}
return sum
}
struct SearchOverlay: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
let q = normalizeAnswer(vm.searchQuery)
let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }.prefix(24)
VStack(alignment: .leading, spacing: 12) {
HStack {
ZStack(alignment: .leading) {
if vm.searchQuery.isEmpty {
Text(vm.tr("search_placeholder")).foregroundStyle(p.overlay)
}
TextField("", text: $vm.searchQuery)
.foregroundStyle(p.text)
.tint(p.accent)
}
.padding(14)
.background(p.mantle, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
PillButton(label: vm.tr("back"), palette: p) { vm.searchOpen = false }
}
if hits.isEmpty {
Text(q.isEmpty ? vm.tr("search_hint") : vm.tr("search_empty")).foregroundStyle(p.subtext)
} else {
ScrollView {
VStack(spacing: 8) {
ForEach(Array(hits), id: \.target) { hit in
Button {
if !hit.locked {
vm.goPage(hit.target)
vm.searchOpen = false
}
} label: {
CardBox(palette: p) {
VStack(alignment: .leading) {
Text(hit.title).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text)
Text(hit.sub).font(.system(size: 13)).foregroundStyle(p.subtext)
}
}
}
.buttonStyle(.plain)
.opacity(hit.locked ? 0.5 : 1)
}
}
}
}
Spacer(minLength: 0)
}
.padding(16)
.background(p.base.opacity(0.96))
}
}
private struct Hit: Identifiable {
var id: String { target + title }
let title: String
let sub: String
let target: String
let locked: Bool
let hay: String
}
private func buildSearch(_ vm: AppModel) -> [Hit] {
var out: [Hit] = []
func add(_ title: String, _ sub: String, _ target: String, locked: Bool = false, extra: String = "") {
out.append(Hit(title: title, sub: sub, target: target, locked: locked, hay: "\(title) \(sub) \(target) \(extra)"))
}
add(vm.tr("subjects_title"), vm.tr("search_page"), "subjects", extra: "predmety")
add(vm.tr("stats"), vm.tr("search_page"), "stats")
add(vm.tr("search_home"), vm.tr("search_page"), "welcome")
for s in vm.content.subjects {
add(vm.tr(s.str("key")), vm.tr("search_subject"), s.strOrNull("target") ?? "subjects", locked: !s.bool("open"))
}
for u in vm.content.german {
let unlocked = u.bool("unlocked")
add(u.str("title"), vm.tr("search_unit"), u.strOrNull("page") ?? "roadmap", locked: !unlocked, extra: "deutsch")
if unlocked {
for (i, name) in u.strs("names").enumerated() {
add(name, u.str("title"), "u\(u.int("id") + 1)e\(i + 1)")
}
if u.has("branch") { add("Vokabeltraining", u.str("title"), u.str("branch"), extra: "vokabel") }
}
}
for l in vm.content.netLessons {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "netunit\(l.int("id"))", extra: "site")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "netex\(l.int("id"))")
}
for l in vm.content.hw {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "hwunit\(l.int("id"))", extra: "hardware")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "hwex\(l.int("id"))")
}
add(vm.tr("Mluvnice"), vm.tr("search_lesson"), "mluvnice")
add(vm.tr("Maturitní četba"), vm.tr("search_book"), "readinglist")
for b in vm.content.books {
add(b.str("title"), vm.tr("search_book"), b.str("page"))
}
return out
}
struct RoadmapScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let current = vm.content.german.firstIndex { u in
u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) }
}
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("roadmap_title"), subtitle: vm.tr("roadmap_sub"))
.padding(.horizontal, 12)
PathMap(
nodes: vm.content.german.enumerated().map { i, u in
let unlocked = u.bool("unlocked")
let names = u.strs("names")
let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) }
return MapNode(
id: u.str("page"),
label: u.str("title"),
locked: !unlocked,
done: done,
current: i == current,
finish: i == vm.content.german.count - 1,
onClick: unlocked ? { vm.go(.unitMap(u.int("id"))) } : nil
)
},
palette: vm.palette
)
.padding(.horizontal, 12)
}
}
}
struct UnitMapScreen: View {
@ObservedObject var vm: AppModel
let unitId: Int
var body: some View {
if let u = vm.content.germanUnit(unitId) {
let names = u.strs("names")
let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) }
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: u.str("title"), subtitle: vm.tr(u.str("sub")))
.padding(.horizontal, 12)
PathMap(
nodes: names.enumerated().map { i, name in
MapNode(
id: "e\(i + 1)",
label: name,
locked: false,
done: vm.progress.germanDone(unitId, i + 1),
current: i == next,
onClick: { vm.go(.germanEx(unitId, i + 1)) }
)
},
palette: vm.palette
)
.padding(.horizontal, 12)
if u.has("branch") {
PrimaryButton(label: vm.tr("Vokabeltraining"), palette: vm.palette) { vm.go(.vocab(unitId)) }
.padding(.bottom, 16)
}
}
}
}
}
struct NetYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_years_title"), subtitle: vm.tr("net_years_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
yearCard(vm.tr("net_year1"), vm.tr("net_sub"), true) { vm.go(.netMap) }
ForEach(["net_year2", "net_year3", "net_year4"], id: \.self) { key in
yearCard(vm.tr(key), vm.tr("net_year_locked_sub"), false) {}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
}
}
private func yearCard(_ title: String, _ sub: String, _ open: Bool, _ action: @escaping () -> Void) -> some View {
let p = vm.palette
return Button(action: action) {
CardBox(palette: p) {
HStack {
VStack(alignment: .leading) {
Text(title).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text)
Text(sub).font(.system(size: 14)).foregroundStyle(p.subtext)
}
Spacer()
if !open { LockIcon(color: p.overlay) }
}
}
}
.buttonStyle(.plain)
.disabled(!open)
.opacity(open ? 1 : 0.48)
}
}
struct LessonMapScreen: View {
@ObservedObject var vm: AppModel
let title: String
let sub: String
let nodes: [MapNode]
var body: some View {
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
.padding(.horizontal, 12)
PathMap(nodes: nodes, palette: vm.palette)
.padding(.horizontal, 12)
}
}
}
struct SlidesScreen: View {
@ObservedObject var vm: AppModel
let title: String
let sub: String
let slides: [J]
var onExercise: () -> Void
@State private var idx = 0
var body: some View {
let p = vm.palette
if let slide = slides.indices.contains(idx) ? slides[idx] : nil {
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
.padding(.horizontal, 16)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text(slide.str("kicker")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
Text(slide.str("title")).font(.system(size: 24, weight: .bold)).foregroundStyle(p.text)
if let tip = slide.strOrNull("tip") {
CardBox(palette: p) { Text(tip).foregroundStyle(p.text) }
}
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
CardBox(palette: p) { Text(line).font(.system(size: 16)).foregroundStyle(p.text) }
}
}
.padding(.horizontal, 16)
}
HStack {
if idx > 0 {
PillButton(label: vm.tr("net_slide_prev"), palette: p) { idx -= 1 }
}
Spacer()
if idx < slides.count - 1 {
PrimaryButton(label: vm.tr("net_slide_next"), palette: p) { idx += 1 }
} else {
PrimaryButton(label: vm.tr("net_slide_start"), palette: p, action: onExercise)
}
}
.padding(12)
}
}
}
}
struct CzechMapScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
let items: [(String, String, Route?)] = [
("L", vm.tr("Literatura"), nil),
("M", vm.tr("Mluvnice"), .mluvnice),
("Č", vm.tr("Maturitní četba"), .readingList),
]
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Český jazyk a literatura"), subtitle: vm.tr("czech_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
ForEach(items, id: \.0) { letter, name, route in
Button {
if let route { vm.go(route) }
} label: {
CardBox(palette: p) {
HStack(spacing: 14) {
ZStack {
Circle().fill(route == nil ? p.lockedBg : p.accent).frame(width: 56, height: 56)
if route == nil {
LockIcon(color: p.overlay)
} else {
Text(letter).font(.system(size: 20, weight: .heavy)).foregroundStyle(p.onAccent)
}
}
Text(name).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text)
Spacer()
}
}
}
.buttonStyle(.plain)
.disabled(route == nil)
.opacity(route == nil ? 0.48 : 1)
}
}
.padding(.horizontal, 16)
}
}
}
}
struct BookListScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
Button { vm.go(.book(b.str("id"))) } label: {
CardBox(palette: p) {
HStack(spacing: 12) {
BookIcon(color: p.text, size: 28)
VStack(alignment: .leading) {
Text(b.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr(b.str("subKey"))).font(.system(size: 13)).foregroundStyle(p.subtext)
}
Spacer()
}
}
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
}
}
}
}
struct BookScreen: View {
@ObservedObject var vm: AppModel
let id: String
var body: some View {
if let b = vm.content.book(id) {
let p = vm.palette
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: b.str("title"), subtitle: vm.tr(b.str("subKey")))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
Button { vm.go(.bookQuiz(id)) } label: {
CardBox(palette: p) {
HStack(spacing: 10) {
QuizIcon(color: p.text)
VStack(alignment: .leading) {
Text(vm.tr(b.str("quizTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr(b.str("quizSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
}
}
}
}
.buttonStyle(.plain)
Button { vm.go(.bookPlot(id)) } label: {
CardBox(palette: p) {
HStack(spacing: 10) {
OrderIcon(color: p.text)
VStack(alignment: .leading) {
Text(vm.tr(b.str("plotTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr(b.str("plotSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
}
}
}
}
.buttonStyle(.plain)
ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in
CardBox(palette: p) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
noteIcon(note.str("icon"), p.text)
Text(note.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
}
ForEach(note.strs("lines"), id: \.self) { line in
Text("• \(line)").font(.system(size: 15)).foregroundStyle(p.text)
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 20)
}
}
}
}
}