Graduately/ios/Graduately/UI/ExercisesMore.swift
pepaskopec-blip 6fbfed86f8 Keep the listening text on screen and tuck speed and volume into a short column.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 18:11:35 +02:00

1785 lines
66 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AVFoundation
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(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.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(.subheadline.weight(.semibold)).foregroundStyle(.secondary)
}
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) }
ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
FlowLayout(spacing: 4) {
if let num = block.num {
Text(num).font(.body.weight(.semibold)).foregroundStyle(.primary)
}
ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(.primary)
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(.caption).foregroundStyle(.secondary)
}
}
.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) }
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: {
HStack(spacing: 4) {
Text(pick.isEmpty ? "…" : pick)
.foregroundStyle(pick.isEmpty ? Color.secondary : Color.primary)
.frame(minWidth: 36)
Image(systemName: mark == true ? "checkmark.circle.fill" : mark == false ? "xmark.circle.fill" : "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(mark == true ? p.success : mark == false ? p.error : Color.secondary)
}
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(mark == true ? p.success : mark == false ? p.error : .secondary)
Group {
if inline {
chip
} else {
FlowLayout(spacing: 6) {
if !prefix.isEmpty { Text(prefix) }
chip
if !suffix.isEmpty { Text(suffix) }
}
.frame(maxWidth: .infinity, alignment: .leading)
.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
}
.sheetDetents()
}
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 {
List {
ForEach(Array(pool.enumerated()), id: \.offset) { _, w in
Button {
onPick(w)
} label: {
Text(w)
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(PressScaleStyle())
}
}
.listStyle(.plain)
.scrollContentBackground(.visible)
.navigationTitle("Vyberte slovo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(role: .cancel) { onPick(nil) } label: { Image(systemName: "xmark") }
}
}
}
.sheetDetents()
}
}
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(.primary).padding(.trailing, 6)
}
ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(.primary)
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")
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"))
}
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(.secondary) }
FlowLayout {
ForEach(row.parts.indices, id: \.self) { pi in
switch row.parts[pi] {
case .text(let s):
Text(s).foregroundStyle(.primary)
case .field(let idx, let ans):
WordField(
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
placeholder: "",
mark: revealed ? answerAccepts(normalizeAnswer(idx < fields.count ? fields[idx] : ""), ans) : nil,
palette: p
)
.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(.body.weight(.bold)).foregroundStyle(.primary)
ForEach(persons.indices, id: \.self) { c in
let i = r * persons.count + c
Text(persons[c]).font(.caption).foregroundStyle(.secondary)
WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), placeholder: "", palette: p)
}
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) }
ForEach(items.indices, id: \.self) { i in
Prompt(text: items[i].str("prompt"))
PillButton(label: vm.tr("show_sample")) { 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 SciQuizScreen: View {
@ObservedObject var vm: AppModel
let area: String
let id: Int
var body: some View {
let lesson = area == "bio" ? vm.content.bioLesson(id) : vm.content.chemLesson(id)
if let lesson {
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"))) {
if area == "bio" { vm.markBio(id) } else { vm.markChem(id) }
}
}
}
}
private func mathLesson(_ vm: AppModel, year: Int, id: Int) -> J? {
switch year {
case 0: return vm.content.mat0Lesson(id)
case 2: return vm.content.mat2Lesson(id)
case 3: return vm.content.mat3Lesson(id)
case 4: return vm.content.mat4Lesson(id)
default: return vm.content.matLesson(id)
}
}
private func markMathLesson(_ vm: AppModel, year: Int, id: Int) {
switch year {
case 0: vm.markMat0(id)
case 2: vm.markMat2(id)
case 3: vm.markMat3(id)
case 4: vm.markMat4(id)
default: vm.markMat(id)
}
}
struct MathPracticeScreen: View {
@ObservedObject var vm: AppModel
let year: Int
let id: Int
@State private var text: [Int: String] = [:]
@State private var points: [Int: [(Int, Int)]] = [:]
@State private var marks: [Int: Bool] = [:]
@State private var revealed = false
@State private var feedback = ""
@State private var kind = ""
var body: some View {
if let lesson = mathLesson(vm, year: year, id: id) {
let problems = lesson.arr("problems")
ExerciseScaffold(
vm: vm,
title: vm.tr(lesson.str("exTitleKey")),
sub: vm.tr(lesson.str("quizHeadKey")),
action: vm.tr("check"),
onAction: { check(problems) },
feedback: feedback,
kind: kind
) {
Text(vm.tr("math_quiz_intro"))
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
ForEach(problems.indices, id: \.self) { i in
mathCard(problems[i], index: i)
}
}
.onChange(of: year) { _, _ in reset() }
.onChange(of: id) { _, _ in reset() }
}
}
private func reset() {
text = [:]
points = [:]
marks = [:]
revealed = false
feedback = ""
kind = ""
}
private func check(_ problems: [J]) {
var all = !problems.isEmpty
var next: [Int: Bool] = [:]
for i in problems.indices {
let spec = problems[i].str("answer")
let good = mathIsDraw(spec)
? mathDrawOk(spec, points[i] ?? [])
: mathAnswerOk(spec, text[i] ?? "")
next[i] = good
if !good { all = false }
}
marks = next
revealed = true
if all {
feedback = vm.tr("feedback_ok")
kind = "ok"
markMathLesson(vm, year: year, id: id)
} else {
feedback = vm.tr("feedback_retry_short")
kind = "bad"
}
}
@ViewBuilder
private func mathCard(_ problem: J, index i: Int) -> some View {
let spec = problem.str("answer")
let p = vm.palette
let border: Color = {
guard let mark = marks[i] else { return p.surface2 }
return mark ? p.success : p.error
}()
VStack(alignment: .leading, spacing: 8) {
Text(problem.str("prompt"))
.frame(maxWidth: .infinity, alignment: .leading)
if mathIsDraw(spec) {
Text(vm.tr("math_draw_hint"))
.font(.caption)
.foregroundStyle(.secondary)
MathGrid(
given: mathDrawGiven(spec),
user: points[i] ?? [],
need: mathDrawNeed(spec),
mark: marks[i],
palette: p
) { next in
points[i] = next
}
HStack {
Button(vm.tr("math_undo")) {
var pts = points[i] ?? []
if !pts.isEmpty {
pts.removeLast()
points[i] = pts
}
}
Button(vm.tr("math_clear")) { points[i] = [] }
}
.buttonStyle(.bordered)
} else {
TextField(vm.tr("math_answer_ph"), text: Binding(
get: { text[i] ?? "" },
set: { text[i] = $0 }
))
.textFieldStyle(.roundedBorder)
#if os(iOS)
.textInputAutocapitalization(.never)
.keyboardType(.numbersAndPunctuation)
#endif
.autocorrectionDisabled()
}
if revealed {
Text(problem.str("hint"))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(p.surface0, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(border, lineWidth: 1.5))
}
}
private struct MathGrid: View {
let given: [(Int, Int)]
let user: [(Int, Int)]
let need: Int
let mark: Bool?
let palette: Palette
var onChange: ([(Int, Int)]) -> Void
private let cell: CGFloat = 28
private let pad: CGFloat = 18
private let gx = 10
private let gy = 8
private var width: CGFloat { pad * 2 + CGFloat(gx) * cell }
private var height: CGFloat { pad * 2 + CGFloat(gy) * cell }
private func screen(_ x: Int, _ y: Int) -> CGPoint {
CGPoint(x: pad + CGFloat(x) * cell, y: height - pad - CGFloat(y) * cell)
}
private func snap(_ loc: CGPoint) -> (Int, Int)? {
let cx = (loc.x - pad) / cell
let cy = (height - pad - loc.y) / cell
let ix = Int(cx.rounded())
let iy = Int(cy.rounded())
if ix < 0 || ix > gx || iy < 0 || iy > gy { return nil }
if abs(cx - CGFloat(ix)) > 0.45 || abs(cy - CGFloat(iy)) > 0.45 { return nil }
return (ix, iy)
}
var body: some View {
Canvas { ctx, _ in
let rect = CGRect(x: 0, y: 0, width: width, height: height)
ctx.fill(Path(rect), with: .color(palette.surface1))
var grid = Path()
for i in 0...gx {
let x = pad + CGFloat(i) * cell
grid.move(to: CGPoint(x: x, y: pad))
grid.addLine(to: CGPoint(x: x, y: height - pad))
}
for i in 0...gy {
let y = pad + CGFloat(i) * cell
grid.move(to: CGPoint(x: pad, y: y))
grid.addLine(to: CGPoint(x: width - pad, y: y))
}
ctx.stroke(grid, with: .color(palette.subtext.opacity(0.55)), lineWidth: 1)
var axes = Path()
axes.move(to: CGPoint(x: pad, y: height - pad))
axes.addLine(to: CGPoint(x: width - pad, y: height - pad))
axes.move(to: CGPoint(x: pad, y: height - pad))
axes.addLine(to: CGPoint(x: pad, y: pad))
ctx.stroke(axes, with: .color(palette.text), lineWidth: 1.6)
if given.count >= 2 {
var seg = Path()
seg.move(to: screen(given[0].0, given[0].1))
seg.addLine(to: screen(given[1].0, given[1].1))
if given.count >= 3 {
seg.move(to: screen(given[0].0, given[0].1))
seg.addLine(to: screen(given[2].0, given[2].1))
}
ctx.stroke(seg, with: .color(palette.accent), lineWidth: 2)
}
let names = ["A", "B", "C", "D"]
for (i, g) in given.enumerated() where i < names.count {
let pt = screen(g.0, g.1)
ctx.fill(Path(ellipseIn: CGRect(x: pt.x - 5.5, y: pt.y - 5.5, width: 11, height: 11)), with: .color(palette.accent))
ctx.draw(
Text(names[i]).font(.system(size: 13, weight: .semibold)).foregroundStyle(palette.text),
at: CGPoint(x: pt.x + 8, y: pt.y - 8),
anchor: .bottomLeading
)
}
let dot = mark == false ? palette.error : palette.success
for (i, u) in user.enumerated() {
let pt = screen(u.0, u.1)
ctx.fill(Path(ellipseIn: CGRect(x: pt.x - 5.5, y: pt.y - 5.5, width: 11, height: 11)), with: .color(dot))
ctx.draw(
Text("\(i + 1)").font(.system(size: 12, weight: .bold)).foregroundStyle(palette.text),
at: CGPoint(x: pt.x + 8, y: pt.y - 8),
anchor: .bottomLeading
)
}
}
.frame(width: width, height: height)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0).onEnded { value in
guard let hit = snap(value.location) else { return }
if given.contains(where: { $0.0 == hit.0 && $0.1 == hit.1 }) { return }
var next = user
if let idx = next.firstIndex(where: { $0.0 == hit.0 && $0.1 == hit.1 }) {
next.remove(at: idx)
} else if next.count < need {
next.append(hit)
} else {
return
}
onChange(next)
}
)
}
}
struct ItYearList: View {
@ObservedObject var vm: AppModel
let course: String
let titleKey: String
let subKey: String
let lesson: (Int) -> Route
var body: some View {
LessonListScreen(
vm: vm,
title: vm.tr(titleKey),
subtitle: vm.tr(subKey),
rows: vm.content.itLessons(course).map { l in
let id = l.int("id")
return LessonRow(
id: "\(course)\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.courseDone(course, id),
destination: lesson(id)
)
}
)
}
}
struct ItSlides: View {
@ObservedObject var vm: AppModel
let course: String
let id: Int
let next: Route
var body: some View {
if let l = vm.content.itLesson(course, id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: next)
}
}
}
struct ChoiceLessonScreen: View {
@ObservedObject var vm: AppModel
let lesson: J?
let onDone: () -> Void
var body: some View {
if let lesson {
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")), onDone: onDone)
}
}
}
struct FyzQuizScreen: View {
@ObservedObject var vm: AppModel
let year: Int
let id: Int
var body: some View {
let lesson: J? = {
switch year {
case 2: return vm.content.fyz2Lesson(id)
case 3: return vm.content.fyz3Lesson(id)
case 4: return vm.content.fyz4Lesson(id)
default: return vm.content.fyzLesson(id)
}
}()
if let lesson {
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"))) {
switch year {
case 2: vm.markFyz2(id)
case 3: vm.markFyz3(id)
case 4: vm.markFyz4(id)
default: vm.markFyz(id)
}
}
}
}
}
struct LitYearQuizScreen: View {
@ObservedObject var vm: AppModel
let year: Int
let id: Int
var body: some View {
let lesson: J? = {
switch year {
case 2: return vm.content.lit2Lesson(id)
case 3: return vm.content.lit3Lesson(id)
case 4: return vm.content.lit4Lesson(id)
default: return vm.content.litLesson(id)
}
}()
if let lesson {
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"))) {
switch year {
case 2: vm.markLit2(id)
case 3: vm.markLit3(id)
case 4: vm.markLit4(id)
default: vm.markLit(id)
}
}
}
}
}
struct OnQuizScreen: View {
@ObservedObject var vm: AppModel
let id: Int
var body: some View {
if let lesson = vm.content.onLesson(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.markOn(id)
}
}
}
}
struct On2QuizScreen: View {
@ObservedObject var vm: AppModel
let id: Int
var body: some View {
if let lesson = vm.content.on2Lesson(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.markOn2(id)
}
}
}
}
struct On3QuizScreen: View {
@ObservedObject var vm: AppModel
let id: Int
var body: some View {
if let lesson = vm.content.on3Lesson(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.markOn3(id)
}
}
}
}
struct On4QuizScreen: View {
@ObservedObject var vm: AppModel
let id: Int
var body: some View {
if let lesson = vm.content.on4Lesson(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.markOn4(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
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 }
GlassCard {
Label {
Text(task.str("prompt"))
} icon: {
Image(systemName: done[ti] ? "checkmark.circle.fill" : "\(ti + 1).circle")
.foregroundStyle(done[ti] ? p.success : .secondary)
}
}
.padding(.vertical, 4)
if infeasible {
Hint(text: vm.tr("net_infeasible"))
} else {
ForEach(block.indices, id: \.self) { si in
Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.top, 6)
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 }), placeholder: "síť", palette: p)
.keyboardType(.decimalPad)
WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), placeholder: "broadcast", palette: p)
.keyboardType(.decimalPad)
WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), placeholder: "první uzel", palette: p)
.keyboardType(.decimalPad)
WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), placeholder: "poslední uzel", palette: p)
.keyboardType(.decimalPad)
}
PillButton(label: vm.tr("check")) {
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")) {
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) }
Divider().padding(.vertical, 8)
}
}
.exerciseContent()
}
.scrollDismissesKeyboard(.interactively)
.detailScreen(vm.tr("net_ex_title"))
.glassBottomBar {
if !fb.0.isEmpty {
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
.padding(.horizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
}
}
.animation(Motion.feedback, value: fb.0)
}
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: {
HStack {
Text("Prefix").foregroundStyle(.secondary)
Spacer()
Text(value.isEmpty ? "/…" : value)
.foregroundStyle(value.isEmpty ? Color.secondary : Color.primary)
.monospacedDigit()
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
}
}
.buttonStyle(.bordered)
.tint(.secondary)
.sheet(isPresented: $open) {
WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
if let word { value = word }
open = false
}
.sheetDetents()
}
}
}
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
@State private var picked = -1
var body: some View {
if let book = vm.content.book(id) {
let qs = book.arr("quiz")
let p = vm.palette
ScrollView {
VStack(alignment: .leading, spacing: 14) {
if finished {
GlassCard {
VStack(alignment: .leading, spacing: 10) {
Label(vm.tr("lit_finished"), systemImage: "rosette")
#if os(macOS)
.font(.largeTitle.weight(.bold))
#else
.font(.title2.weight(.bold))
#endif
Text(vm.tr("lit_finished_text"))
#if os(macOS)
.font(.title3)
#endif
.foregroundStyle(.secondary)
Text(vm.fmt("lit_score_fmt", score, qs.count))
#if os(macOS)
.font(.title2.weight(.semibold))
#else
.font(.headline)
#endif
}
}
} else if idx < qs.count {
let q = qs[idx]
ProgressView(value: Double(idx + (answered ? 1 : 0)), total: Double(qs.count))
HStack {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count))
Spacer()
Text(vm.fmt("lit_score_fmt", score, qs.count))
}
#if os(macOS)
.font(.body.weight(.semibold))
#else
.font(.footnote)
#endif
.foregroundStyle(.secondary)
Text(q.str("prompt"))
#if os(macOS)
.font(.title.weight(.semibold))
#else
.font(.title3.weight(.semibold))
#endif
.padding(.vertical, 12)
#if os(macOS)
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12, alignment: .top),
GridItem(.flexible(), spacing: 12, alignment: .top),
],
spacing: 8
) {
bookQuizOptions(q)
}
#else
bookQuizOptions(q)
#endif
if answered {
Meaning(text: q.str("expl"), palette: p, visible: true)
}
}
}
.exerciseContent()
}
.detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
.glassBottomBar {
if finished {
BottomAction(label: vm.tr("lit_again"), action: {
idx = 0; score = 0; answered = false; finished = false; picked = -1
}) { EmptyView() }
} else if answered {
BottomAction(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), action: {
if idx + 1 >= qs.count {
finished = true
vm.markBookQuiz(id)
} else {
idx += 1
answered = false
picked = -1
}
}) { EmptyView() }
}
}
.animation(Motion.settle, value: answered)
}
}
@ViewBuilder
private func bookQuizOptions(_ q: J) -> some View {
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
let correct = i == q.int("correct")
let chosen = answered && picked == i
OptionChip(
text: opt,
selected: chosen,
mark: answered ? (correct ? true : (chosen ? false : nil)) : nil
) {
guard !answered else { return }
picked = i
answered = true
if correct { score += 1 }
}
}
}
}
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)
}
}
}
}
private final class EnSpeaker: NSObject, ObservableObject, AVSpeechSynthesizerDelegate, @unchecked Sendable {
enum Phase { case idle, playing, paused }
private let synth = AVSpeechSynthesizer()
private var text = ""
private var base = 0
private var cursor = 0
private var restarting = false
private var settingsDirty = false
@Published var rate: Float = 0.9
@Published var volume: Float = 1
@Published var phase: Phase = .idle
var language = "en-GB"
var onFinish: (() -> Void)?
override init() {
super.init()
synth.delegate = self
}
func toggle(_ full: String) {
if text != full {
text = full
cursor = 0
base = 0
synth.stopSpeaking(at: .immediate)
phase = .idle
}
switch phase {
case .idle:
cursor = 0
start(from: 0)
case .playing:
synth.pauseSpeaking(at: .immediate)
phase = .paused
case .paused:
if settingsDirty || !synth.isPaused {
start(from: cursor)
} else {
synth.continueSpeaking()
phase = .playing
}
settingsDirty = false
}
}
func applySettings() {
settingsDirty = true
guard phase == .playing else { return }
restarting = true
synth.stopSpeaking(at: .immediate)
start(from: cursor)
settingsDirty = false
}
private func start(from index: Int) {
let ns = text as NSString
let start = min(max(0, index), ns.length)
base = start
let rest = ns.substring(from: start)
let utterance = AVSpeechUtterance(string: rest.isEmpty ? text : rest)
if rest.isEmpty { base = 0 }
utterance.voice = AVSpeechSynthesisVoice(language: language)
?? AVSpeechSynthesisVoice(language: language.hasPrefix("de") ? "de-DE" : "en-US")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * rate
utterance.volume = volume
synth.speak(utterance)
phase = .playing
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
cursor = base + characterRange.location
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
DispatchQueue.main.async {
self.phase = .idle
self.cursor = 0
self.onFinish?()
}
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
if restarting {
restarting = false
return
}
DispatchQueue.main.async {
if self.phase == .playing { self.phase = .idle }
}
}
}
struct EngExScreen: View {
@ObservedObject var vm: AppModel
let year: Int
let id: Int
let voice: String
let lessonOf: (AppModel, Int, Int) -> J?
let mark: (AppModel, Int, Int) -> Void
@StateObject private var speaker: EnSpeaker
@State private var readPicks: [Int]
@State private var listenPicks: [Int]
@State private var quizPicks: [Int]
@State private var gaps: [String]
@State private var essay = ""
@State private var played = false
@State private var revealed = false
@State private var showModel = false
@State private var fb = ("", "")
init(vm: AppModel, year: Int, id: Int, voice: String = "en-GB",
lesson: @escaping (AppModel, Int, Int) -> J? = { $0.content.enLesson($1, $2) },
mark: @escaping (AppModel, Int, Int) -> Void = { $0.markEn($1, $2) }) {
self.vm = vm
self.year = year
self.id = id
self.voice = voice
self.lessonOf = lesson
self.mark = mark
let row = lesson(vm, year, id)
_readPicks = State(initialValue: Array(repeating: -1, count: row?.arr("readQuiz").count ?? 0))
_listenPicks = State(initialValue: Array(repeating: -1, count: row?.arr("listenQuiz").count ?? 0))
_quizPicks = State(initialValue: Array(repeating: -1, count: row?.arr("quiz").count ?? 0))
_gaps = State(initialValue: Array(repeating: "", count: row?.arr("gaps").count ?? 0))
let speaker = EnSpeaker()
speaker.language = voice
_speaker = StateObject(wrappedValue: speaker)
}
var body: some View {
if let lesson = lessonOf(vm, year, id) {
let listening = lesson.strOrNull("listening")
let pinListen = listening != nil && lesson.strOrNull("reading") == nil
if pinListen, let listening {
scaffold(lesson) {
exerciseBody(lesson, inlineListening: nil)
}
.safeAreaInset(edge: .top, spacing: 0) {
listenBlock(listening)
.padding(.horizontal, pagePad)
.padding(.top, 4)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.background)
}
} else {
scaffold(lesson) {
exerciseBody(lesson, inlineListening: listening)
}
}
}
}
private func scaffold<Content: View>(_ lesson: J, @ViewBuilder content: @escaping () -> Content) -> some View {
ExerciseScaffold(
vm: vm,
title: vm.tr(lesson.str("titleKey")),
sub: nil,
action: vm.tr("check"),
onAction: { check(lesson) },
feedback: fb.0,
kind: fb.1,
content: content
)
}
@ViewBuilder
private func exerciseBody(_ lesson: J, inlineListening: String?) -> some View {
let p = vm.palette
if let reading = lesson.strOrNull("reading") {
section("en_sec_read")
Text(reading).textSelection(.enabled)
choiceBlock(lesson.arr("readQuiz"), picks: $readPicks)
}
if let inlineListening {
listenBlock(inlineListening)
choiceBlock(lesson.arr("listenQuiz"), picks: $listenPicks)
} else if lesson.strOrNull("listening") != nil {
choiceBlock(lesson.arr("listenQuiz"), picks: $listenPicks)
}
let gapRows = lesson.arr("gaps")
if !gapRows.isEmpty {
section("en_sec_gap")
ForEach(gapRows.indices, id: \.self) { i in
Text("\(i + 1). \(gapRows[i].str("prompt"))")
.font(.body.weight(.semibold))
WordField(
value: Binding(get: { gaps[i] }, set: { gaps[i] = $0 }),
placeholder: "",
mark: revealed ? answerAccepts(normalizeAnswer(gaps[i]), gapRows[i].str("answers")) : nil,
palette: p
)
if revealed, let hint = gapRows[i].strOrNull("meaning") {
Meaning(text: hint, palette: p, visible: true)
}
}
}
if let writing = lesson.obj("writing") {
section("en_sec_write")
Text(writing.str("prompt"))
TextEditor(text: $essay)
.frame(minHeight: 140)
.padding(8)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12))
if showModel {
Text(vm.tr("en_model"))
.font(.subheadline.weight(.semibold))
Text(writing.str("model")).textSelection(.enabled)
}
}
let quiz = lesson.arr("quiz")
if !quiz.isEmpty {
section("en_sec_quiz")
choiceBlock(quiz, picks: $quizPicks)
}
}
private var pagePad: CGFloat {
#if os(macOS)
MacChrome.pageInset
#else
16
#endif
}
private func listenBlock(_ text: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
section("en_sec_listen")
Text(vm.tr("en_listen_note"))
.font(.subheadline)
.foregroundStyle(.secondary)
GlassCard {
#if os(macOS)
HStack(alignment: .top, spacing: 22) {
listenControls(text)
listenScript(text)
}
#else
VStack(alignment: .leading, spacing: 14) {
listenScript(text)
listenControls(text)
}
#endif
}
}
}
private func listenScript(_ text: String) -> some View {
Text(text)
.font(.body)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func section(_ key: String) -> some View {
Text(vm.tr(key))
.font(.title3.weight(.bold))
.padding(.top, 8)
}
private func listenControls(_ text: String) -> some View {
let label: String = switch speaker.phase {
case .playing: vm.tr("en_pause")
case .paused: vm.tr("en_resume")
case .idle: vm.tr("en_play")
}
return VStack(alignment: .leading, spacing: 12) {
Button(label) {
played = true
speaker.toggle(text)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(maxWidth: .infinity)
listenSlider(vm.tr("en_speed"), value: Double(speaker.rate), range: 0.6...1.5, caption: String(format: "%.1f×", speaker.rate)) { next in
speaker.rate = Float(next)
}
listenSlider(vm.tr("en_volume"), value: Double(speaker.volume), range: 0...1, caption: "\(Int(speaker.volume * 100)) %") { next in
speaker.volume = Float(next)
}
}
.frame(width: 228)
}
private func listenSlider(_ title: String, value: Double, range: ClosedRange<Double>, caption: String, set: @escaping (Double) -> Void) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Slider(
value: Binding(get: { value }, set: set),
in: range,
onEditingChanged: { editing in
if !editing { speaker.applySettings() }
}
)
.controlSize(.small)
Text(caption)
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(width: 46, alignment: .trailing)
}
}
}
private func choiceBlock(_ questions: [J], picks: Binding<[Int]>) -> some View {
let p = vm.palette
return ForEach(questions.indices, id: \.self) { i in
let q = questions[i]
VStack(alignment: .leading, spacing: 6) {
Text("\(i + 1). \(q.str("prompt"))")
.font(.body.weight(.semibold))
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { o, opt in
let selected = picks.wrappedValue[i] == o
let mark: Bool? = revealed && selected ? picks.wrappedValue[i] == q.int("correct") : nil
OptionChip(text: opt, selected: selected, mark: mark) {
picks.wrappedValue[i] = o
}
}
if revealed, let expl = q.strOrNull("expl") {
Meaning(text: expl, palette: p, visible: true)
}
}
.padding(.bottom, 8)
}
}
private func check(_ lesson: J) {
if lesson.strOrNull("listening") != nil && !played {
fb = (vm.tr("en_listen_first"), "err")
return
}
let readOk = score(lesson.arr("readQuiz"), readPicks)
let listenOk = score(lesson.arr("listenQuiz"), listenPicks)
let quizOk = score(lesson.arr("quiz"), quizPicks)
let gapRows = lesson.arr("gaps")
let gapOk = gapRows.indices.filter {
answerAccepts(normalizeAnswer(gaps[$0]), gapRows[$0].str("answers"))
}.count
var writeOk = true
if let writing = lesson.obj("writing") {
let keys = writing.str("keys").split(separator: "|").map(String.init)
let enough = wordCount(essay) >= writing.int("minWords", 20)
writeOk = enough && keys.allSatisfy { hasWord(essay, $0) }
}
revealed = true
let all = readOk && listenOk && quizOk && gapOk == gapRows.count && writeOk
if all {
fb = (vm.tr("feedback_ok"), "ok")
showModel = true
mark(vm, year, id)
} else if lesson.obj("writing") != nil && !writeOk && readOk && listenOk && quizOk && gapOk == gapRows.count {
fb = (vm.tr("en_write_short"), "err")
} else {
fb = (vm.tr("feedback_retry_short"), "err")
}
}
private func score(_ questions: [J], _ picks: [Int]) -> Bool {
questions.indices.allSatisfy { picks[$0] == questions[$0].int("correct") }
}
private func wordCount(_ text: String) -> Int {
let n = normalizeAnswer(text)
return n.isEmpty ? 0 : n.split(separator: " ").count
}
private func hasWord(_ text: String, _ word: String) -> Bool {
let n = " \(normalizeAnswer(text)) "
let w = normalizeAnswer(word)
return !w.isEmpty && n.contains(" \(w) ")
}
}