mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 07:04:35 +00:00
1785 lines
66 KiB
Swift
1785 lines
66 KiB
Swift
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) ")
|
||
}
|
||
}
|
||
|