Add four years of English from the gymnasium syllabus.

Each year mixes listening, reading, writing, gap-fill, quizzes, and a year test so the subject is usable through the maturita.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-07 10:42:28 +02:00
commit bb93ec76ec
32 changed files with 6607 additions and 14 deletions

View file

@ -166,6 +166,11 @@ final class AppModel: ObservableObject {
refresh()
}
func markEn(_ year: Int, _ id: Int) {
progress.markEn(year, id)
refresh()
}
func markLit(_ id: Int) {
progress.markLit(id)
refresh()

View file

@ -35,6 +35,10 @@ final class Content {
let on2: [J]
let on3: [J]
let on4: [J]
let en: [J]
let en2: [J]
let en3: [J]
let en4: [J]
let lit: [J]
let lit2: [J]
let lit3: [J]
@ -85,6 +89,10 @@ final class Content {
on2 = raw.arr("on2")
on3 = raw.arr("on3")
on4 = raw.arr("on4")
en = raw.arr("en")
en2 = raw.arr("en2")
en3 = raw.arr("en3")
en4 = raw.arr("en4")
lit = raw.arr("lit")
lit2 = raw.arr("lit2")
lit3 = raw.arr("lit3")
@ -112,6 +120,15 @@ final class Content {
func on2Lesson(_ id: Int) -> J? { on2.first { $0.int("id") == id } }
func on3Lesson(_ id: Int) -> J? { on3.first { $0.int("id") == id } }
func on4Lesson(_ id: Int) -> J? { on4.first { $0.int("id") == id } }
func enYear(_ year: Int) -> [J] {
switch year {
case 2: return en2
case 3: return en3
case 4: return en4
default: return en
}
}
func enLesson(_ year: Int, _ id: Int) -> J? { enYear(year).first { $0.int("id") == id } }
func litLesson(_ id: Int) -> J? { lit.first { $0.int("id") == id } }
func lit2Lesson(_ id: Int) -> J? { lit2.first { $0.int("id") == id } }
func lit3Lesson(_ id: Int) -> J? { lit3.first { $0.int("id") == id } }

View file

@ -46,6 +46,9 @@ final class ProgressStore {
func on4Done(_ id: Int) -> Bool { defaults.bool(forKey: "on4.\(id)") }
func markOn4(_ id: Int) { defaults.set(true, forKey: "on4.\(id)") }
func enDone(_ year: Int, _ id: Int) -> Bool { defaults.bool(forKey: "en.\(year).\(id)") }
func markEn(_ year: Int, _ id: Int) { defaults.set(true, forKey: "en.\(year).\(id)") }
func litDone(_ id: Int) -> Bool { defaults.bool(forKey: "lit.\(id)") }
func markLit(_ id: Int) { defaults.set(true, forKey: "lit.\(id)") }
func lit2Done(_ id: Int) -> Bool { defaults.bool(forKey: "lit2.\(id)") }
@ -153,6 +156,16 @@ func summarize(_ content: Content, _ p: ProgressStore) -> ProgressSum {
sum.doneUnits += 1
}
}
for year in 1...4 {
for l in content.enYear(year) {
sum.totalEx += 1
sum.openUnits += 1
if p.enDone(year, l.int("id")) {
sum.doneEx += 1
sum.doneUnits += 1
}
}
}
for l in content.lit {
sum.totalEx += 1
sum.openUnits += 1

View file

@ -36,6 +36,10 @@ enum Route: Hashable {
case on4Map
case on4Lesson(Int)
case on4Ex(Int)
case enYears
case enMap(Int)
case enLesson(Int, Int)
case enEx(Int, Int)
case litYears
case litMap
case litLesson(Int)
@ -123,6 +127,10 @@ enum Route: Hashable {
case .on4Map: return "on4map"
case .on4Lesson(let id): return "on4unit\(id)"
case .on4Ex(let id): return "on4ex\(id)"
case .enYears: return "enyears"
case .enMap(let year): return "en\(year)map"
case .enLesson(let year, let id): return "en\(year)unit\(id)"
case .enEx(let year, let id): return "en\(year)ex\(id)"
case .litYears: return "lityears"
case .litMap: return "litmap"
case .litLesson(let id): return "litunit\(id)"
@ -246,6 +254,14 @@ enum Route: Hashable {
return [.onYears, .on4Map, .on4Lesson(id)]
case .on4Ex(let id):
return [.onYears, .on4Map, .on4Lesson(id), .on4Ex(id)]
case .enYears:
return [.enYears]
case .enMap(let year):
return [.enYears, .enMap(year)]
case .enLesson(let year, let id):
return [.enYears, .enMap(year), .enLesson(year, id)]
case .enEx(let year, let id):
return [.enYears, .enMap(year), .enLesson(year, id), .enEx(year, id)]
case .litYears:
return [.czechMap, .litYears]
case .litMap:
@ -377,6 +393,7 @@ func routeFromPage(_ page: String) -> Route? {
case "on2map": return .on2Map
case "on3map": return .on3Map
case "on4map": return .on4Map
case "enyears": return .enYears
case "lityears": return .litYears
case "litmap": return .litMap
case "lit2map": return .lit2Map
@ -529,6 +546,15 @@ func routeFromPage(_ page: String) -> Route? {
if let m = page.wholeMatch(of: /litex(\d+)/), let n = Int(m.1) {
return .litEx(n)
}
if let m = page.wholeMatch(of: /en(\d)map/), let y = Int(m.1) {
return .enMap(y)
}
if let m = page.wholeMatch(of: /en(\d)unit(\d+)/), let y = Int(m.1), let n = Int(m.2) {
return .enLesson(y, n)
}
if let m = page.wholeMatch(of: /en(\d)ex(\d+)/), let y = Int(m.1), let n = Int(m.2) {
return .enEx(y, n)
}
if let m = page.wholeMatch(of: /on4unit(\d+)/), let n = Int(m.1) {
return .on4Lesson(n)
}

View file

@ -625,6 +625,7 @@ struct SubjectIcon: View {
switch icon {
case "de": GermanFlag(size: size)
case "cz": CzechFlag(size: size)
case "uk": UkFlag(size: size)
default:
Image(systemName: subjectSymbol(icon))
.font(.system(size: size * 0.72, weight: .semibold))

View file

@ -1,3 +1,4 @@
import AVFoundation
import SwiftUI
struct DialogEx: View {
@ -1345,3 +1346,188 @@ struct PlotScreen: View {
}
}
private final class EnSpeaker: ObservableObject {
private let synth = AVSpeechSynthesizer()
func speak(_ text: String) {
if synth.isSpeaking { synth.stopSpeaking(at: .immediate) }
let utterance = AVSpeechUtterance(string: text)
utterance.voice = AVSpeechSynthesisVoice(language: "en-GB")
?? AVSpeechSynthesisVoice(language: "en-US")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9
synth.speak(utterance)
}
}
struct EngExScreen: View {
@ObservedObject var vm: AppModel
let year: Int
let id: Int
@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 showScript = false
@State private var revealed = false
@State private var showModel = false
@State private var fb = ("", "")
init(vm: AppModel, year: Int, id: Int) {
self.vm = vm
self.year = year
self.id = id
let lesson = vm.content.enLesson(year, id)
_readPicks = State(initialValue: Array(repeating: -1, count: lesson?.arr("readQuiz").count ?? 0))
_listenPicks = State(initialValue: Array(repeating: -1, count: lesson?.arr("listenQuiz").count ?? 0))
_quizPicks = State(initialValue: Array(repeating: -1, count: lesson?.arr("quiz").count ?? 0))
_gaps = State(initialValue: Array(repeating: "", count: lesson?.arr("gaps").count ?? 0))
_speaker = StateObject(wrappedValue: EnSpeaker())
}
var body: some View {
if let lesson = vm.content.enLesson(year, id) {
let p = vm.palette
ExerciseScaffold(vm: vm, title: vm.tr(lesson.str("titleKey")), sub: nil, action: vm.tr("check"), onAction: {
check(lesson)
}, feedback: fb.0, kind: fb.1) {
if let reading = lesson.strOrNull("reading") {
section("en_sec_read")
Text(reading).textSelection(.enabled)
choiceBlock(lesson.arr("readQuiz"), picks: $readPicks)
}
if let listening = lesson.strOrNull("listening") {
section("en_sec_listen")
Text(vm.tr("en_listen_note"))
.font(.subheadline)
.foregroundStyle(.secondary)
PillButton(label: vm.tr("en_play")) {
played = true
showScript = false
speaker.speak(listening)
let wait = max(2.5, Double(listening.split(separator: " ").count) * 0.42)
DispatchQueue.main.asyncAfter(deadline: .now() + wait) {
showScript = true
}
}
if showScript {
Text(listening).textSelection(.enabled)
}
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 func section(_ key: String) -> some View {
Text(vm.tr(key))
.font(.title3.weight(.bold))
.padding(.top, 8)
}
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
vm.markEn(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) ")
}
}

View file

@ -33,3 +33,27 @@ struct CzechFlag: View {
.frame(width: size, height: size)
}
}
struct UkFlag: View {
var size: CGFloat = 48
var body: some View {
Canvas { ctx, sz in
let w = sz.width
let h = sz.height
ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
ctx.fill(Path(CGRect(origin: .zero, size: sz)), with: .color(Color(red: 0.01, green: 0.14, blue: 0.40)))
var saltire = Path()
saltire.move(to: .zero)
saltire.addLine(to: CGPoint(x: w, y: h))
saltire.move(to: CGPoint(x: w, y: 0))
saltire.addLine(to: CGPoint(x: 0, y: h))
ctx.stroke(saltire, with: .color(.white), lineWidth: w * 0.16)
ctx.stroke(saltire, with: .color(Color(red: 0.80, green: 0.05, blue: 0.15)), lineWidth: w * 0.055)
ctx.fill(Path(CGRect(x: 0, y: h * 0.38, width: w, height: h * 0.24)), with: .color(.white))
ctx.fill(Path(CGRect(x: w * 0.38, y: 0, width: w * 0.24, height: h)), with: .color(.white))
ctx.fill(Path(CGRect(x: 0, y: h * 0.43, width: w, height: h * 0.14)), with: .color(Color(red: 0.80, green: 0.05, blue: 0.15)))
ctx.fill(Path(CGRect(x: w * 0.43, y: 0, width: w * 0.14, height: h)), with: .color(Color(red: 0.80, green: 0.05, blue: 0.15)))
}
.frame(width: size, height: size)
}
}

View file

@ -689,6 +689,11 @@ private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum {
case "hwyears", "hwmap":
sum.totalEx = vm.content.hw.count
sum.doneEx = vm.content.hw.filter { vm.progress.hwDone($0.int("id")) }.count
case "enyears", "en1map", "en2map", "en3map", "en4map":
sum.totalEx = (1...4).reduce(0) { $0 + vm.content.enYear($1).count }
sum.doneEx = (1...4).reduce(0) { acc, year in
acc + vm.content.enYear(year).filter { vm.progress.enDone(year, $0.int("id")) }.count
}
case "onyears", "onmap", "on2map", "on3map", "on4map":
sum.totalEx = vm.content.on.count + vm.content.on2.count + vm.content.on3.count + vm.content.on4.count
sum.doneEx = vm.content.on.filter { vm.progress.onDone($0.int("id")) }.count
@ -905,6 +910,12 @@ private func buildSearch(_ vm: AppModel) -> [Hit] {
add(vm.tr("on_year2"), vm.tr("search_lesson"), "on2map", extra: "rocnik")
add(vm.tr("on_year3"), vm.tr("search_lesson"), "on3map", extra: "rocnik")
add(vm.tr("on_year4"), vm.tr("search_lesson"), "on4map", extra: "rocnik")
for year in 1...4 {
add(vm.tr("en_year\(year)"), vm.tr("search_lesson"), "en\(year)map", extra: "anglictina english rocnik")
for l in vm.content.enYear(year) {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "en\(year)unit\(l.int("id"))", extra: "anglictina english")
}
}
for l in vm.content.on {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "onunit\(l.int("id"))", extra: "obcanka")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "onex\(l.int("id"))")
@ -1247,6 +1258,40 @@ struct HwYearsScreen: View {
}
}
struct EnYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("en_year1", "en_y1_sub", .enMap(1)),
("en_year2", "en_y2_sub", .enMap(2)),
("en_year3", "en_y3_sub", .enMap(3)),
("en_year4", "en_y4_sub", .enMap(4)),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
}
.navigationTitle(vm.tr("English"))
.navigationSubtitle(vm.tr("en_years_sub"))
#else
List {
ForEach(1...4, id: \.self) { year in
NavigationLink(value: Route.enMap(year)) {
ListRowLabel(title: vm.tr("en_year\(year)"), subtitle: vm.tr("en_y\(year)_sub"))
}
}
}
.navigationTitle(vm.tr("English"))
.navigationSubtitle(vm.tr("en_years_sub"))
.appListStyle()
#endif
}
}
struct OnYearsScreen: View {
@ObservedObject var vm: AppModel
@ -2183,6 +2228,29 @@ struct RouteDestination: View {
HwQuizScreen(vm: vm, id: id)
case .onYears:
OnYearsScreen(vm: vm)
case .enYears:
EnYearsScreen(vm: vm)
case .enMap(let year):
LessonListScreen(
vm: vm,
title: vm.tr("en_year\(year)"),
subtitle: vm.tr("en_y\(year)_sub"),
rows: vm.content.enYear(year).map { l in
let id = l.int("id")
return LessonRow(
id: "e\(year)\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.enDone(year, id),
destination: .enLesson(year, id)
)
}
)
case .enLesson(let year, let id):
if let l = vm.content.enLesson(year, id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .enEx(year, id))
}
case .enEx(let year, let id):
EngExScreen(vm: vm, year: year, id: id)
case .onMap:
LessonListScreen(
vm: vm,