Add four years of German listening, reading and writing beside the textbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-07 19:30:00 +02:00
commit b822c57875
31 changed files with 3589 additions and 102 deletions

View file

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

View file

@ -39,6 +39,10 @@ final class Content {
let en2: [J]
let en3: [J]
let en4: [J]
let de: [J]
let de2: [J]
let de3: [J]
let de4: [J]
let lit: [J]
let lit2: [J]
let lit3: [J]
@ -93,6 +97,10 @@ final class Content {
en2 = raw.arr("en2")
en3 = raw.arr("en3")
en4 = raw.arr("en4")
de = raw.arr("de")
de2 = raw.arr("de2")
de3 = raw.arr("de3")
de4 = raw.arr("de4")
lit = raw.arr("lit")
lit2 = raw.arr("lit2")
lit3 = raw.arr("lit3")
@ -129,6 +137,15 @@ final class Content {
}
}
func enLesson(_ year: Int, _ id: Int) -> J? { enYear(year).first { $0.int("id") == id } }
func deYear(_ year: Int) -> [J] {
switch year {
case 2: return de2
case 3: return de3
case 4: return de4
default: return de
}
}
func deLesson(_ year: Int, _ id: Int) -> J? { deYear(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

@ -48,6 +48,8 @@ final class ProgressStore {
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 deDone(_ year: Int, _ id: Int) -> Bool { defaults.bool(forKey: "de.\(year).\(id)") }
func markDe(_ year: Int, _ id: Int) { defaults.set(true, forKey: "de.\(year).\(id)") }
func litDone(_ id: Int) -> Bool { defaults.bool(forKey: "lit.\(id)") }
func markLit(_ id: Int) { defaults.set(true, forKey: "lit.\(id)") }
@ -165,6 +167,14 @@ func summarize(_ content: Content, _ p: ProgressStore) -> ProgressSum {
sum.doneUnits += 1
}
}
for l in content.deYear(year) {
sum.totalEx += 1
sum.openUnits += 1
if p.deDone(year, l.int("id")) {
sum.doneEx += 1
sum.doneUnits += 1
}
}
}
for l in content.lit {
sum.totalEx += 1

View file

@ -12,6 +12,10 @@ enum Route: Hashable {
case subjects
case stats
case roadmap
case deHome
case deMap(Int)
case deLesson(Int, Int)
case deEx(Int, Int)
case unitMap(Int)
case germanEx(Int, Int)
case vocab(Int)
@ -103,6 +107,10 @@ enum Route: Hashable {
case .subjects: return "subjects"
case .stats: return "stats"
case .roadmap: return "roadmap"
case .deHome: return "dehome"
case .deMap(let year): return "de\(year)map"
case .deLesson(let year, let id): return "de\(year)unit\(id)"
case .deEx(let year, let id): return "de\(year)ex\(id)"
case .unitMap(let unitId): return "unit\(unitId + 1)"
case .germanEx(let unitId, let ex): return "u\(unitId + 1)e\(ex)"
case .vocab(let unitId): return "u\(unitId + 1)vocab"
@ -204,14 +212,22 @@ enum Route: Hashable {
switch self {
case .welcome, .subjects, .stats:
return []
case .deHome:
return [.deHome]
case .deMap(let year):
return [.deHome, .deMap(year)]
case .deLesson(let year, let id):
return [.deHome, .deMap(year), .deLesson(year, id)]
case .deEx(let year, let id):
return [.deHome, .deMap(year), .deLesson(year, id), .deEx(year, id)]
case .roadmap:
return [.roadmap]
return [.deHome, .roadmap]
case .unitMap(let id):
return [.roadmap, .unitMap(id)]
return [.deHome, .roadmap, .unitMap(id)]
case .germanEx(let unit, let ex):
return [.roadmap, .unitMap(unit), .germanEx(unit, ex)]
return [.deHome, .roadmap, .unitMap(unit), .germanEx(unit, ex)]
case .vocab(let unit):
return [.roadmap, .unitMap(unit), .vocab(unit)]
return [.deHome, .roadmap, .unitMap(unit), .vocab(unit)]
case .netYears:
return [.netYears]
case .netMap:
@ -384,6 +400,7 @@ func routeFromPage(_ page: String) -> Route? {
case "subjects": return .subjects
case "stats": return .stats
case "roadmap": return .roadmap
case "dehome": return .deHome
case "netyears": return .netYears
case "netmap": return .netMap
case "hwyears": return .hwYears
@ -546,6 +563,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: /de(\d)map/), let y = Int(m.1) {
return .deMap(y)
}
if let m = page.wholeMatch(of: /de(\d)unit(\d+)/), let y = Int(m.1), let n = Int(m.2) {
return .deLesson(y, n)
}
if let m = page.wholeMatch(of: /de(\d)ex(\d+)/), let y = Int(m.1), let n = Int(m.2) {
return .deEx(y, n)
}
if let m = page.wholeMatch(of: /en(\d)map/), let y = Int(m.1) {
return .enMap(y)
}

View file

@ -1359,6 +1359,7 @@ private final class EnSpeaker: NSObject, ObservableObject, AVSpeechSynthesizerDe
@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() {
@ -1408,8 +1409,8 @@ private final class EnSpeaker: NSObject, ObservableObject, AVSpeechSynthesizerDe
let rest = ns.substring(from: start)
let utterance = AVSpeechUtterance(string: rest.isEmpty ? text : rest)
if rest.isEmpty { base = 0 }
utterance.voice = AVSpeechSynthesisVoice(language: "en-GB")
?? AVSpeechSynthesisVoice(language: "en-US")
utterance.voice = AVSpeechSynthesisVoice(language: language)
?? AVSpeechSynthesisVoice(language: language.hasPrefix("de") ? "de-DE" : "en-US")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * rate
utterance.volume = volume
synth.speak(utterance)
@ -1443,6 +1444,9 @@ 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]
@ -1455,20 +1459,27 @@ struct EngExScreen: View {
@State private var showModel = false
@State private var fb = ("", "")
init(vm: AppModel, year: Int, id: Int) {
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
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())
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 = vm.content.enLesson(year, id) {
if let lesson = lessonOf(vm, year, id) {
let p = vm.palette
ExerciseScaffold(vm: vm, title: vm.tr(lesson.str("titleKey")), sub: nil, action: vm.tr("check"), onAction: {
check(lesson)
@ -1619,7 +1630,7 @@ struct EngExScreen: View {
if all {
fb = (vm.tr("feedback_ok"), "ok")
showModel = true
vm.markEn(year, id)
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 {

View file

@ -662,12 +662,16 @@ struct StatsScreen: View {
private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum {
var sum = ProgressSum()
switch s.str("target") {
case "roadmap":
case "dehome":
for u in vm.content.german where u.bool("unlocked") {
let n = u.strs("names").count
sum.totalEx += n
sum.doneEx += (1...n).filter { vm.progress.germanDone(u.int("id"), $0) }.count
}
sum.totalEx += (1...4).reduce(0) { $0 + vm.content.deYear($1).count }
sum.doneEx += (1...4).reduce(0) { acc, year in
acc + vm.content.deYear(year).filter { vm.progress.deDone(year, $0.int("id")) }.count
}
case "netyears":
sum.totalEx = vm.content.netLessons.count
sum.doneEx = vm.content.netLessons.filter { vm.progress.netDone($0.int("id")) }.count
@ -900,6 +904,10 @@ private func buildSearch(_ vm: AppModel) -> [Hit] {
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")
}
add(vm.tr("de_year\(year)"), vm.tr("search_lesson"), "de\(year)map", extra: "nemcina deutsch rocnik")
for l in vm.content.deYear(year) {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "de\(year)unit\(l.int("id"))", extra: "nemcina deutsch")
}
}
for l in vm.content.on {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "onunit\(l.int("id"))", extra: "obcanka")
@ -1243,6 +1251,43 @@ struct HwYearsScreen: View {
}
}
struct DeHomeScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let rows: [(String, String, String, Route)] = [
("U", "de_book_title", "de_book_sub", .roadmap),
("1", "de_year1", "de_y1_sub", .deMap(1)),
("2", "de_year2", "de_y2_sub", .deMap(2)),
("3", "de_year3", "de_y3_sub", .deMap(3)),
("4", "de_year4", "de_y4_sub", .deMap(4)),
]
MacFillGrid(count: rows.count, minWidth: 260, minHeight: 180) { i in
NavigationLink(value: rows[i].3) {
MacLinkCard(title: vm.tr(rows[i].1), subtitle: vm.tr(rows[i].2), badge: rows[i].0)
}
.buttonStyle(.plain)
}
.navigationTitle(vm.tr("Deutsch"))
.navigationSubtitle(vm.tr("de_home_sub"))
#else
List {
NavigationLink(value: Route.roadmap) {
ListRowLabel(title: vm.tr("de_book_title"), subtitle: vm.tr("de_book_sub"))
}
ForEach(1...4, id: \.self) { year in
NavigationLink(value: Route.deMap(year)) {
ListRowLabel(title: vm.tr("de_year\(year)"), subtitle: vm.tr("de_y\(year)_sub"))
}
}
}
.navigationTitle(vm.tr("Deutsch"))
.navigationSubtitle(vm.tr("de_home_sub"))
#endif
}
}
struct EnYearsScreen: View {
@ObservedObject var vm: AppModel
@ -2157,6 +2202,29 @@ struct RouteDestination: View {
PracticeHome(vm: vm)
case .stats:
StatsScreen(vm: vm)
case .deHome:
DeHomeScreen(vm: vm)
case .deMap(let year):
LessonListScreen(
vm: vm,
title: vm.tr("de_year\(year)"),
subtitle: vm.tr("de_y\(year)_sub"),
rows: vm.content.deYear(year).map { l in
let id = l.int("id")
return LessonRow(
id: "d\(year)\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.deDone(year, id),
destination: .deLesson(year, id)
)
}
)
case .deLesson(let year, let id):
if let l = vm.content.deLesson(year, id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .deEx(year, id))
}
case .deEx(let year, let id):
EngExScreen(vm: vm, year: year, id: id, voice: "de-DE", lesson: { $0.content.deLesson($1, $2) }, mark: { $0.markDe($1, $2) })
case .roadmap:
RoadmapScreen(vm: vm)
case .unitMap(let id):