Graduately/ios/Graduately/UI/Screens.swift
pepaskopec-blip ec7aa63159 Open presentation graphics as four years from Photoshop to the exam campaign.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 18:12:17 +02:00

3780 lines
155 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 SwiftUI
#if os(macOS)
import AppKit
#endif
struct PracticeHome: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
macHome.softAppear()
#else
iosHome.softAppear()
#endif
}
#if os(macOS)
private var macHome: some View {
let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
let open = vm.content.subjects.filter { $0.bool("open") }
let soon = vm.content.subjects.filter { !$0.bool("open") }
return ScrollView {
VStack(alignment: .leading, spacing: 28) {
if vm.showChangelog {
ChangelogCard(vm: vm)
}
welcomeCard(sum: sum, pct: pct)
VStack(alignment: .leading, spacing: 14) {
Text(vm.tr("subjects_title"))
.font(.title2.weight(.semibold))
LazyVGrid(
columns: [GridItem(.adaptive(minimum: MacChrome.homeTileMin), spacing: 16, alignment: .top)],
alignment: .leading,
spacing: 16
) {
ForEach(Array(open.enumerated()), id: \.offset) { _, s in
subjectTile(s)
}
}
}
if !soon.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text(vm.tr("subjects_soon"))
.font(.headline)
.foregroundStyle(.secondary)
FlowLayout(spacing: 10) {
ForEach(Array(soon.enumerated()), id: \.offset) { _, s in
Label(vm.tr(s.str("key")), systemImage: "lock.fill")
.font(.body)
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(Color.primary.opacity(0.05), in: Capsule())
}
}
}
}
}
.padding(MacChrome.pageInset)
.macPage()
}
.navigationTitle("Graduately")
}
private func welcomeCard(sum: ProgressSum, pct: Double) -> some View {
GlassCard {
HStack(alignment: .center, spacing: 32) {
VStack(alignment: .leading, spacing: 12) {
Text(vm.tr("welcome_title"))
.font(.system(size: 34, weight: .bold))
.fixedSize(horizontal: false, vertical: true)
Text(vm.tr(Self.welcomeBodyKey))
.font(.title3)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
ProgressRing(value: pct) {
VStack(spacing: 2) {
Text(vm.fmt("stats_pct_fmt", Int((pct * 100).rounded(.down))))
.font(.system(size: 26, weight: .bold, design: .rounded))
.monospacedDigit()
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.frame(width: 132, height: 132)
}
.padding(.vertical, 6)
}
}
@ViewBuilder
private func subjectTile(_ s: J) -> some View {
let dest = s.strOrNull("target").flatMap(routeFromPage)
if let dest, dest != .subjects, dest != .welcome {
NavigationLink(value: dest) {
subjectTileBody(s)
}
.buttonStyle(PressScaleStyle())
} else {
subjectTileBody(s)
}
}
private func subjectTileBody(_ s: J) -> some View {
let part = subjectSum(vm, s)
let progress = part.totalEx > 0 ? Double(part.doneEx) / Double(part.totalEx) : 0
return VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .center) {
SubjectIcon(icon: s.str("icon"), open: true, size: 40)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.body.weight(.semibold))
.foregroundStyle(.tertiary)
}
Text(vm.tr(s.str("key")))
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.lineLimit(2)
Spacer(minLength: 0)
ProgressBar(value: progress)
HStack {
Text(vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx))
Spacer(minLength: 0)
Text(vm.fmt("stats_pct_fmt", Int((progress * 100).rounded(.down))))
}
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: MacChrome.homeTileHeight)
.softSurface(cornerRadius: 22)
.contentShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
}
#endif
#if os(iOS)
private var iosHome: some View {
let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
return List {
if vm.showChangelog {
Section {
ChangelogCard(vm: vm)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
Section {
GlassCard {
VStack(alignment: .leading, spacing: 10) {
Text(vm.tr("welcome_title"))
.font(.title.bold())
Text(vm.tr(Self.welcomeBodyKey))
.font(.subheadline)
.foregroundStyle(.secondary)
ProgressView(value: pct)
.animation(Motion.settle, value: pct)
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
Section(vm.tr("subjects_title")) {
ForEach(Array(vm.content.subjects.filter { $0.bool("open") }.enumerated()), id: \.offset) { _, s in
let dest = s.strOrNull("target").flatMap(routeFromPage)
if let dest, dest != .subjects, dest != .welcome {
NavigationLink(value: dest) {
subjectLabel(s)
}
} else {
subjectLabel(s)
}
}
}
let soon = vm.content.subjects.filter { !$0.bool("open") }
if !soon.isEmpty {
Section(vm.tr("subjects_soon")) {
ForEach(Array(soon.enumerated()), id: \.offset) { _, s in
Label {
Text(vm.tr(s.str("key")))
} icon: {
SubjectIcon(icon: s.str("icon"), open: false)
}
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Graduately")
.navigationBarTitleDisplayMode(.large)
}
private func subjectLabel(_ s: J) -> some View {
let part = subjectSum(vm, s)
return Label {
VStack(alignment: .leading, spacing: 6) {
Text(vm.tr(s.str("key")))
if part.totalEx > 0 {
ProgressLine(done: part.doneEx, total: part.totalEx)
}
}
.padding(.vertical, 2)
} icon: {
SubjectIcon(icon: s.str("icon"), open: true)
}
}
#endif
private static var welcomeBodyKey: String {
#if os(macOS)
"welcome_body_macos"
#else
"welcome_body_ios"
#endif
}
}
struct ChangelogCard: View {
@ObservedObject var vm: AppModel
var body: some View {
let langKey = vm.lang == .en ? "en" : "cs"
let blocks = Array(vm.content.changelog.prefix(3))
GlassCard {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label(vm.tr("changelog_title"), systemImage: "sparkles")
.font(.headline)
Spacer()
if let date = blocks.first?.strOrNull("date") {
Text(date)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in
ChangelogLines(index: index, lines: changelogLines(block, langKey))
}
Button(vm.tr("changelog_dismiss")) { vm.dismissChangelog() }
#if os(macOS)
.buttonStyle(.borderedProminent)
.controlSize(.large)
#else
.buttonStyle(.glassProminent)
.controlSize(.small)
#endif
}
}
}
}
private func changelogLines(_ block: J, _ langKey: String) -> [String] {
let preferred = block.strs(langKey)
return preferred.isEmpty ? block.strs("cs") : preferred
}
private struct ChangelogLines: View {
let index: Int
let lines: [String]
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if index > 0 { Spacer().frame(height: 4) }
VStack(alignment: .leading, spacing: 4) { lineList }
}
}
@ViewBuilder
private var lineList: some View {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text("• \(line)")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct SettingsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
macSettings
#else
settingsForm
#endif
}
#if os(iOS)
private var settingsForm: some View {
Form {
Section(vm.tr("mode")) {
Picker(vm.tr("mode"), selection: modeBinding) {
Text(vm.tr("mode_dark")).tag(ColorMode.dark)
Text(vm.tr("mode_light")).tag(ColorMode.light)
}
.pickerStyle(.segmented)
#if os(macOS)
.controlSize(.large)
#endif
}
Section(vm.tr("theme")) {
ForEach(Array(ThemeId.allCases), id: \.self) { id in
Button {
vm.setTheme(id)
} label: {
HStack(spacing: 12) {
Circle().fill(themePalette(id, vm.mode).tint)
.frame(width: {
#if os(macOS)
18
#else
14
#endif
}(), height: {
#if os(macOS)
18
#else
14
#endif
}())
Text(themeNames[id.rawValue])
#if os(macOS)
.font(.title3)
#endif
.foregroundStyle(.primary)
Spacer()
if vm.themeId == id {
Image(systemName: "checkmark").fontWeight(.semibold)
}
}
#if os(macOS)
.padding(.vertical, 4)
#endif
}
}
}
Section(vm.tr("language")) {
Picker(vm.tr("language"), selection: langBinding) {
Text("Čeština").tag(UiLang.cs)
Text("English").tag(UiLang.en)
}
.pickerStyle(.segmented)
#if os(macOS)
.controlSize(.large)
#endif
}
Section(vm.tr("updates")) {
Text(updateText)
#if os(macOS)
.font(.body)
#endif
.foregroundStyle(.secondary)
Button(vm.tr("update_check")) { vm.checkUpdate(true) }
#if os(macOS)
.controlSize(.large)
#endif
if vm.update.canInstall {
Button(vm.tr("update_install")) { vm.installUpdate() }
#if os(macOS)
.controlSize(.large)
#endif
}
LabeledContent("Build", value: "\(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
}
}
.navigationTitle(vm.tr("settings_title"))
.appFormStyle()
}
#endif
#if os(macOS)
private var macSettings: some View {
let themes = Array(ThemeId.allCases)
return VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 16) {
settingsCard(vm.tr("mode")) {
Picker(vm.tr("mode"), selection: modeBinding) {
Text(vm.tr("mode_dark")).tag(ColorMode.dark)
Text(vm.tr("mode_light")).tag(ColorMode.light)
}
.pickerStyle(.segmented)
.labelsHidden()
.controlSize(.large)
}
settingsCard(vm.tr("language")) {
Picker(vm.tr("language"), selection: langBinding) {
Text("Čeština").tag(UiLang.cs)
Text("English").tag(UiLang.en)
}
.pickerStyle(.segmented)
.labelsHidden()
.controlSize(.large)
}
settingsCard(vm.tr("updates")) {
Text(updateText)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button(vm.tr("update_check")) { vm.checkUpdate(true) }
.controlSize(.large)
if vm.update.canInstall {
Button(vm.tr("update_install")) { vm.installUpdate() }
.controlSize(.large)
}
LabeledContent("Build", value: "\(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
}
}
.layoutPriority(1)
Text(vm.tr("theme"))
.font(.title2.weight(.semibold))
.layoutPriority(1)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 280), spacing: 12)], spacing: 12) {
ForEach(themes, id: \.self) { id in
Button {
vm.setTheme(id)
} label: {
HStack(spacing: 12) {
Circle().fill(themePalette(id, vm.mode).tint)
.frame(width: 18, height: 18)
Text(themeNames[id.rawValue])
.font(.title3)
.foregroundStyle(.primary)
Spacer(minLength: 0)
if vm.themeId == id {
Image(systemName: "checkmark").fontWeight(.semibold)
}
}
.padding(16)
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
.softSurface(cornerRadius: 16)
}
.buttonStyle(PressScaleStyle())
}
}
}
.padding(MacChrome.pageInset)
.frame(maxWidth: MacChrome.contentWidth, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.navigationTitle(vm.tr("settings_title"))
}
private func settingsCard<Content: View>(_ title: String, @ViewBuilder content: @escaping () -> Content) -> some View {
GlassCard {
VStack(alignment: .leading, spacing: 12) {
Text(title).font(.headline)
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
#endif
private var modeBinding: Binding<ColorMode> {
Binding(get: { vm.mode }, set: { vm.applyMode($0) })
}
private var langBinding: Binding<UiLang> {
Binding(get: { vm.lang }, set: { vm.applyLang($0) })
}
private var updateText: String {
if let arg = vm.update.messageArg {
return vm.fmt(vm.update.messageKey, arg)
}
return vm.tr(vm.update.messageKey)
}
}
struct StatsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
macStats
#else
iosStats
#endif
}
#if os(iOS)
private var iosStats: some View {
let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
return List {
Section {
GlassCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
}
ProgressView(value: pct)
.animation(Motion.settle, value: pct)
}
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
Section(vm.tr("stats_section")) {
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
let open = s.bool("open")
let part = subjectSum(vm, s)
Label {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(vm.tr(s.str("key")))
#if os(macOS)
.font(.title3)
#endif
Spacer()
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
#if os(macOS)
.font(.body)
#else
.font(.caption)
#endif
.foregroundStyle(.secondary)
.monospacedDigit()
}
if open && part.totalEx > 0 {
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
.animation(Motion.settle, value: part.doneEx)
}
}
} icon: {
SubjectIcon(icon: s.str("icon"), open: open, size: {
#if os(macOS)
32
#else
26
#endif
}())
}
.foregroundStyle(open ? .primary : .secondary)
}
}
}
.navigationTitle(vm.tr("stats_title"))
.appListStyle()
}
#endif
#if os(macOS)
private var macStats: some View {
let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
let ranked = rankedSubjects()
return VStack(alignment: .leading, spacing: 22) {
HStack(spacing: 16) {
statMetric(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
statMetric(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
statMetric(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
}
Text(vm.tr("stats_section"))
.font(.title2.weight(.semibold))
GeometryReader { geo in
let count = max(ranked.count, 1)
let minRow: CGFloat = 54
let floorH = minRow * CGFloat(ranked.count)
let rowH = min(76, max(minRow, geo.size.height / CGFloat(count)))
let table = VStack(spacing: 0) {
ForEach(Array(ranked.enumerated()), id: \.offset) { index, item in
statRow(item.subject, part: item.part)
.frame(height: floorH > geo.size.height + 1 ? minRow : rowH)
if index < ranked.count - 1 {
Divider().padding(.leading, 68)
}
}
}
.softSurface(cornerRadius: 18)
if floorH > geo.size.height + 1 {
ScrollView { table }
} else {
table.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
}
.padding(MacChrome.pageInset)
.frame(maxWidth: MacChrome.contentWidth, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.navigationTitle(vm.tr("stats_title"))
}
private func rankedSubjects() -> [(subject: J, part: ProgressSum)] {
let rows = vm.content.subjects.map { subject in
(subject: subject, part: subjectSum(vm, subject))
}
let open = rows.filter { $0.subject.bool("open") }.sorted { lhs, rhs in
share(lhs.part) > share(rhs.part)
}
let locked = rows.filter { !$0.subject.bool("open") }
return open + locked
}
private func share(_ part: ProgressSum) -> Double {
part.totalEx == 0 ? 0 : Double(part.doneEx) / Double(part.totalEx)
}
private func statMetric(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(value)
.font(.system(size: 40, weight: .bold, design: .rounded))
.monospacedDigit()
.minimumScaleFactor(0.6)
.lineLimit(1)
Text(label)
.font(.headline)
.foregroundStyle(.secondary)
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .leading)
.softSurface(cornerRadius: 18)
}
private func statRow(_ s: J, part: ProgressSum) -> some View {
let open = s.bool("open")
let progress = share(part)
return HStack(spacing: 16) {
SubjectIcon(icon: s.str("icon"), open: open, size: 28)
.frame(width: 36)
Text(vm.tr(s.str("key")))
.font(.title3.weight(.semibold))
.foregroundStyle(open ? .primary : .secondary)
.lineLimit(1)
.frame(width: 240, alignment: .leading)
if open && part.totalEx > 0 {
statTrack(progress)
Text(vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx))
.font(.title3.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 88, alignment: .trailing)
} else {
statTrack(0)
Text(vm.tr("stats_locked"))
.font(.body)
.foregroundStyle(.tertiary)
.frame(width: 88, alignment: .trailing)
}
}
.padding(.horizontal, 18)
}
private func statTrack(_ progress: Double) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.primary.opacity(0.08))
Capsule()
.fill(Color.accentColor)
.frame(width: max(0, geo.size.width * progress))
}
}
.frame(height: 12)
.frame(maxWidth: .infinity)
}
#endif
private func stat(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
#if os(macOS)
.font(.subheadline)
#else
.font(.caption)
#endif
.foregroundStyle(.secondary)
Text(value)
#if os(macOS)
.font(.title2.bold())
#else
.font(.title3.bold())
#endif
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Done and total exercises behind one card of a years screen, or nil when there is no count.
func routeProgress(_ vm: AppModel, _ r: Route) -> (done: Int, total: Int)? {
let c = vm.content
let p = vm.progress
func tally(_ lessons: [J], _ done: (Int) -> Bool) -> (done: Int, total: Int) {
(lessons.filter { done($0.int("id")) }.count, lessons.count)
}
switch r {
case .netMap: return tally(c.netLessons, p.netDone)
case .net2Map: return tally(c.itLessons("net2")) { p.courseDone("net2", $0) }
case .net3Map: return tally(c.itLessons("net3")) { p.courseDone("net3", $0) }
case .net4Map: return tally(c.itLessons("net4")) { p.courseDone("net4", $0) }
case .hwMap: return tally(c.hw, p.hwDone)
case .hw2Map: return tally(c.itLessons("hw2")) { p.courseDone("hw2", $0) }
case .hw3Map: return tally(c.itLessons("hw3")) { p.courseDone("hw3", $0) }
case .hw4Map: return tally(c.itLessons("hw4")) { p.courseDone("hw4", $0) }
case .onMap: return tally(c.on, p.onDone)
case .on2Map: return tally(c.on2, p.on2Done)
case .on3Map: return tally(c.on3, p.on3Done)
case .on4Map: return tally(c.on4, p.on4Done)
case .enMap(let year): return tally(c.enYear(year)) { p.enDone(year, $0) }
case .deMap(let year): return tally(c.deYear(year)) { p.deDone(year, $0) }
case .roadmap:
var done = 0
var total = 0
for u in c.german where u.bool("unlocked") {
let n = u.strs("names").count
total += n
done += (0..<n).filter { p.germanDone(u.int("id"), $0 + 1) }.count
}
return (done, total)
case .litYears:
let parts = [tally(c.lit, p.litDone), tally(c.lit2, p.lit2Done), tally(c.lit3, p.lit3Done), tally(c.lit4, p.lit4Done)]
return (parts.reduce(0) { $0 + $1.done }, parts.reduce(0) { $0 + $1.total })
case .litMap: return tally(c.lit, p.litDone)
case .lit2Map: return tally(c.lit2, p.lit2Done)
case .lit3Map: return tally(c.lit3, p.lit3Done)
case .lit4Map: return tally(c.lit4, p.lit4Done)
case .mluvnice: return tally(c.mluvnice, p.mluvDone)
case .chemMap: return tally(c.chem, p.chemDone)
case .bioMap: return tally(c.bio, p.bioDone)
case .mat0Map: return tally(c.mat0, p.mat0Done)
case .matMap: return tally(c.mat, p.matDone)
case .mat2Map: return tally(c.mat2, p.mat2Done)
case .mat3Map: return tally(c.mat3, p.mat3Done)
case .mat4Map: return tally(c.mat4, p.mat4Done)
case .fyzMap: return tally(c.fyz, p.fyzDone)
case .fyz2Map: return tally(c.fyz2, p.fyz2Done)
case .fyz3Map: return tally(c.fyz3, p.fyz3Done)
case .fyz4Map: return tally(c.fyz4, p.fyz4Done)
case .tgMap: return tally(c.tg, p.tgDone)
case .tg2Map: return tally(c.tg2, p.tg2Done)
case .tg3Map: return tally(c.tg3, p.tg3Done)
case .tg4Map: return tally(c.tg4, p.tg4Done)
case .pgMap: return tally(c.pg, p.pgDone)
case .pg2Map: return tally(c.pg2, p.pg2Done)
case .pg3Map: return tally(c.pg3, p.pg3Done)
case .pg4Map: return tally(c.pg4, p.pg4Done)
case .csMap: return tally(c.cs, p.csDone)
case .cs2Map: return tally(c.cs2, p.cs2Done)
case .cs3Map: return tally(c.cs3, p.cs3Done)
case .cs4Map: return tally(c.cs4, p.cs4Done)
default: return nil
}
}
private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum {
var sum = ProgressSum()
switch s.str("target") {
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
+ vm.content.net2.count + vm.content.net3.count + vm.content.net4.count
sum.doneEx = vm.content.netLessons.filter { vm.progress.netDone($0.int("id")) }.count
+ ["net2", "net3", "net4"].reduce(0) { acc, course in
acc + vm.content.itLessons(course).filter { vm.progress.courseDone(course, $0.int("id")) }.count
}
case "hwyears", "hwmap":
sum.totalEx = vm.content.hw.count
+ vm.content.hw2.count + vm.content.hw3.count + vm.content.hw4.count
sum.doneEx = vm.content.hw.filter { vm.progress.hwDone($0.int("id")) }.count
+ ["hw2", "hw3", "hw4"].reduce(0) { acc, course in
acc + vm.content.itLessons(course).filter { vm.progress.courseDone(course, $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
+ vm.content.on2.filter { vm.progress.on2Done($0.int("id")) }.count
+ vm.content.on3.filter { vm.progress.on3Done($0.int("id")) }.count
+ vm.content.on4.filter { vm.progress.on4Done($0.int("id")) }.count
case "scimap":
sum.totalEx = vm.content.chem.count + vm.content.bio.count
sum.doneEx = vm.content.chem.filter { vm.progress.chemDone($0.int("id")) }.count
+ vm.content.bio.filter { vm.progress.bioDone($0.int("id")) }.count
case "matyears", "mat0map", "matmap", "mat2map", "mat3map", "mat4map":
sum.totalEx = vm.content.mat0.count + vm.content.mat.count + vm.content.mat2.count + vm.content.mat3.count + vm.content.mat4.count
sum.doneEx = vm.content.mat0.filter { vm.progress.mat0Done($0.int("id")) }.count
+ vm.content.mat.filter { vm.progress.matDone($0.int("id")) }.count
+ vm.content.mat2.filter { vm.progress.mat2Done($0.int("id")) }.count
+ vm.content.mat3.filter { vm.progress.mat3Done($0.int("id")) }.count
+ vm.content.mat4.filter { vm.progress.mat4Done($0.int("id")) }.count
case "csyears", "csmap", "cs2map", "cs3map", "cs4map":
sum.totalEx = vm.content.cs.count + vm.content.cs2.count + vm.content.cs3.count + vm.content.cs4.count
sum.doneEx = vm.content.cs.filter { vm.progress.csDone($0.int("id")) }.count
+ vm.content.cs2.filter { vm.progress.cs2Done($0.int("id")) }.count
+ vm.content.cs3.filter { vm.progress.cs3Done($0.int("id")) }.count
+ vm.content.cs4.filter { vm.progress.cs4Done($0.int("id")) }.count
case "pgyears", "pgmap", "pg2map", "pg3map", "pg4map":
sum.totalEx = vm.content.pg.count + vm.content.pg2.count + vm.content.pg3.count + vm.content.pg4.count
sum.doneEx = vm.content.pg.filter { vm.progress.pgDone($0.int("id")) }.count
+ vm.content.pg2.filter { vm.progress.pg2Done($0.int("id")) }.count
+ vm.content.pg3.filter { vm.progress.pg3Done($0.int("id")) }.count
+ vm.content.pg4.filter { vm.progress.pg4Done($0.int("id")) }.count
case "tgyears", "tgmap", "tg2map", "tg3map", "tg4map":
sum.totalEx = vm.content.tg.count + vm.content.tg2.count + vm.content.tg3.count + vm.content.tg4.count
sum.doneEx = vm.content.tg.filter { vm.progress.tgDone($0.int("id")) }.count
+ vm.content.tg2.filter { vm.progress.tg2Done($0.int("id")) }.count
+ vm.content.tg3.filter { vm.progress.tg3Done($0.int("id")) }.count
+ vm.content.tg4.filter { vm.progress.tg4Done($0.int("id")) }.count
case "fyzyears", "fyzmap", "fyz2map", "fyz3map", "fyz4map":
sum.totalEx = vm.content.fyz.count + vm.content.fyz2.count + vm.content.fyz3.count + vm.content.fyz4.count
sum.doneEx = vm.content.fyz.filter { vm.progress.fyzDone($0.int("id")) }.count
+ vm.content.fyz2.filter { vm.progress.fyz2Done($0.int("id")) }.count
+ vm.content.fyz3.filter { vm.progress.fyz3Done($0.int("id")) }.count
+ vm.content.fyz4.filter { vm.progress.fyz4Done($0.int("id")) }.count
case "czechmap":
let litCount = vm.content.lit.count + vm.content.lit2.count + vm.content.lit3.count + vm.content.lit4.count
let litDone = vm.content.lit.filter { vm.progress.litDone($0.int("id")) }.count
+ vm.content.lit2.filter { vm.progress.lit2Done($0.int("id")) }.count
+ vm.content.lit3.filter { vm.progress.lit3Done($0.int("id")) }.count
+ vm.content.lit4.filter { vm.progress.lit4Done($0.int("id")) }.count
sum.totalEx = vm.content.mluvnice.count + litCount
sum.doneEx = vm.content.mluvnice.filter { vm.progress.mluvDone($0.int("id")) }.count + litDone
default:
break
}
return sum
}
struct SearchScreen: View {
@ObservedObject var vm: AppModel
@State private var query = ""
#if os(iOS)
@FocusState private var fieldFocused: Bool
#endif
var body: some View {
let q = normalizeAnswer(query)
let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }
#if os(macOS)
return macSearch(hits)
#else
return iosSearch(hits)
#endif
}
#if os(macOS)
private func macSearch(_ hits: [Hit]) -> some View {
ScrollView {
if hits.isEmpty {
ContentUnavailableView.search
.frame(maxWidth: .infinity, minHeight: 360)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 340), spacing: 12)], spacing: 12) {
ForEach(hits) { hit in
Button {
if !hit.locked { vm.openFromSearch(hit.target) }
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(hit.title)
.font(.title3.weight(.semibold))
.foregroundStyle(hit.locked ? .secondary : .primary)
.multilineTextAlignment(.leading)
if !hit.sub.isEmpty {
Text(hit.sub)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
}
.padding(16)
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
.softSurface(cornerRadius: 16)
}
.buttonStyle(PressScaleStyle())
.disabled(hit.locked)
}
}
.padding(MacChrome.pageInset)
.macPage()
}
}
.navigationTitle(vm.tr("search"))
.searchable(text: $vm.searchQuery, prompt: vm.tr("search_placeholder"))
.onAppear { query = vm.searchQuery }
.onChange(of: vm.searchQuery) { _, q in query = q }
}
#endif
#if os(iOS)
private func iosSearch(_ hits: [Hit]) -> some View {
List {
if hits.isEmpty {
ContentUnavailableView.search
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else {
ForEach(hits) { hit in
Button {
#if os(iOS)
fieldFocused = false
#endif
if !hit.locked { vm.openFromSearch(hit.target) }
} label: {
ListRowLabel(title: hit.title, subtitle: hit.sub)
}
.disabled(hit.locked)
.foregroundStyle(hit.locked ? .secondary : .primary)
}
}
}
.navigationTitle(vm.tr("search"))
.navigationBarTitleDisplayMode(.inline)
.safeAreaBar(edge: .top) {
searchField
}
.onAppear {
query = vm.searchQuery
DispatchQueue.main.async {
fieldFocused = true
}
}
.onChange(of: query) { _, q in
vm.searchQuery = q
}
.onChange(of: vm.searchQuery) { _, q in
if q != query { query = q }
}
.appListStyle()
}
#endif
#if os(iOS)
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(vm.tr("search_placeholder"), text: $query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.focused($fieldFocused)
if !query.isEmpty {
Button {
query = ""
fieldFocused = true
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(PressScaleStyle())
.accessibilityLabel("Clear")
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
#endif
}
private struct Hit: Identifiable {
var id: String { target + "|" + title }
let title: String
let sub: String
let target: String
let locked: Bool
let hay: String
}
private func buildSearch(_ vm: AppModel) -> [Hit] {
var out: [Hit] = []
func add(_ title: String, _ sub: String, _ target: String, locked: Bool = false, extra: String = "") {
out.append(Hit(title: title, sub: sub, target: target, locked: locked, hay: "\(title) \(sub) \(target) \(extra)"))
}
add(vm.tr("subjects_title"), vm.tr("search_page"), "subjects", extra: "predmety")
add(vm.tr("stats"), vm.tr("search_page"), "stats")
add(vm.tr("search_home"), vm.tr("search_page"), "welcome")
for s in vm.content.subjects {
add(vm.tr(s.str("key")), vm.tr("search_subject"), s.strOrNull("target") ?? "subjects", locked: !s.bool("open"))
}
for u in vm.content.german {
let unlocked = u.bool("unlocked")
add(u.str("title"), vm.tr("search_unit"), u.strOrNull("page") ?? "roadmap", locked: !unlocked, extra: "deutsch")
if unlocked {
for (i, name) in u.strs("names").enumerated() {
add(name, u.str("title"), "u\(u.int("id") + 1)e\(i + 1)")
}
if u.has("branch") { add("Vokabeltraining", u.str("title"), u.str("branch"), extra: "vokabel") }
}
}
for l in vm.content.netLessons {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "netunit\(l.int("id"))", extra: "site")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "netex\(l.int("id"))")
}
for course in ["net2", "net3", "net4"] {
add(vm.tr("net_year\(course.dropFirst(3))"), vm.tr("search_lesson"), "\(course)map", extra: "rocnik site")
for l in vm.content.itLessons(course) {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "\(course)unit\(l.int("id"))", extra: "site")
}
}
add(vm.tr("hw_year1"), vm.tr("search_lesson"), "hwmap", extra: "rocnik")
for l in vm.content.hw {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "hwunit\(l.int("id"))", extra: "hardware")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "hwex\(l.int("id"))")
}
for course in ["hw2", "hw3", "hw4"] {
add(vm.tr("hw_year\(course.dropFirst(2))"), vm.tr("search_lesson"), "\(course)map", extra: "rocnik hardware")
for l in vm.content.itLessons(course) {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "\(course)unit\(l.int("id"))", extra: "hardware")
}
}
add(vm.tr("on_year1"), vm.tr("search_lesson"), "onmap", extra: "rocnik")
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")
}
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")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "onex\(l.int("id"))")
}
for l in vm.content.on2 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "on2unit\(l.int("id"))", extra: "obcanka")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "on2ex\(l.int("id"))")
}
for l in vm.content.on3 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "on3unit\(l.int("id"))", extra: "obcanka")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "on3ex\(l.int("id"))")
}
for l in vm.content.on4 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "on4unit\(l.int("id"))", extra: "obcanka")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "on4ex\(l.int("id"))")
}
add(vm.tr("Literatura"), vm.tr("search_lesson"), "lityears", extra: "literatura")
add(vm.tr("lit_year1"), vm.tr("search_lesson"), "litmap", extra: "rocnik")
add(vm.tr("lit_year2"), vm.tr("search_lesson"), "lit2map", extra: "rocnik")
add(vm.tr("lit_year3"), vm.tr("search_lesson"), "lit3map", extra: "rocnik")
add(vm.tr("lit_year4"), vm.tr("search_lesson"), "lit4map", extra: "rocnik")
for l in vm.content.lit {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "litunit\(l.int("id"))", extra: "literatura")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "litex\(l.int("id"))")
}
for l in vm.content.lit2 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "lit2unit\(l.int("id"))", extra: "literatura")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "lit2ex\(l.int("id"))")
}
for l in vm.content.lit3 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "lit3unit\(l.int("id"))", extra: "literatura")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "lit3ex\(l.int("id"))")
}
for l in vm.content.lit4 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "lit4unit\(l.int("id"))", extra: "literatura")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "lit4ex\(l.int("id"))")
}
add(vm.tr("Matematika"), vm.tr("search_lesson"), "matyears", extra: "matematika")
add(vm.tr("mat0_year"), vm.tr("search_lesson"), "mat0map", extra: "zakladka")
add(vm.tr("mat_year1"), vm.tr("search_lesson"), "matmap", extra: "rocnik")
add(vm.tr("mat_year2"), vm.tr("search_lesson"), "mat2map", extra: "rocnik")
add(vm.tr("mat_year3"), vm.tr("search_lesson"), "mat3map", extra: "rocnik")
add(vm.tr("mat_year4"), vm.tr("search_lesson"), "mat4map", extra: "rocnik")
for l in vm.content.mat0 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "mat0unit\(l.int("id"))", extra: "matematika")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "mat0ex\(l.int("id"))")
}
for l in vm.content.mat {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "matunit\(l.int("id"))", extra: "matematika")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "matex\(l.int("id"))")
}
for l in vm.content.mat2 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "mat2unit\(l.int("id"))", extra: "matematika")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "mat2ex\(l.int("id"))")
}
for l in vm.content.mat3 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "mat3unit\(l.int("id"))", extra: "matematika")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "mat3ex\(l.int("id"))")
}
for l in vm.content.mat4 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "mat4unit\(l.int("id"))", extra: "matematika")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "mat4ex\(l.int("id"))")
}
add(vm.tr("Fyzika"), vm.tr("search_lesson"), "fyzyears", extra: "fyzika")
add(vm.tr("fyz_year1"), vm.tr("search_lesson"), "fyzmap", extra: "rocnik")
add(vm.tr("fyz_year2"), vm.tr("search_lesson"), "fyz2map", extra: "rocnik")
add(vm.tr("fyz_year3"), vm.tr("search_lesson"), "fyz3map", extra: "rocnik")
add(vm.tr("fyz_year4"), vm.tr("search_lesson"), "fyz4map", extra: "rocnik")
for l in vm.content.fyz {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "fyzunit\(l.int("id"))", extra: "fyzika")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "fyzex\(l.int("id"))")
}
for l in vm.content.fyz2 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "fyz2unit\(l.int("id"))", extra: "fyzika")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "fyz2ex\(l.int("id"))")
}
for l in vm.content.fyz3 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "fyz3unit\(l.int("id"))", extra: "fyzika")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "fyz3ex\(l.int("id"))")
}
for l in vm.content.fyz4 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "fyz4unit\(l.int("id"))", extra: "fyzika")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "fyz4ex\(l.int("id"))")
}
add(vm.tr("Prezentační grafika"), vm.tr("search_lesson"), "pgyears", extra: "photoshop premiere illustrator")
add(vm.tr("pg_year1"), vm.tr("search_lesson"), "pgmap", extra: "rocnik")
add(vm.tr("pg_year2"), vm.tr("search_lesson"), "pg2map", extra: "rocnik")
add(vm.tr("pg_year3"), vm.tr("search_lesson"), "pg3map", extra: "rocnik")
add(vm.tr("pg_year4"), vm.tr("search_lesson"), "pg4map", extra: "rocnik")
for l in vm.content.pg {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "pgunit\(l.int("id"))", extra: "photoshop")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "pgex\(l.int("id"))")
}
for l in vm.content.pg2 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "pg2unit\(l.int("id"))", extra: "photoshop")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "pg2ex\(l.int("id"))")
}
for l in vm.content.pg3 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "pg3unit\(l.int("id"))", extra: "premiere")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "pg3ex\(l.int("id"))")
}
for l in vm.content.pg4 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "pg4unit\(l.int("id"))", extra: "illustrator")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "pg4ex\(l.int("id"))")
}
add(vm.tr("Technická grafika"), vm.tr("search_lesson"), "tgyears", extra: "autocad grafika")
add(vm.tr("tg_year1"), vm.tr("search_lesson"), "tgmap", extra: "rocnik")
add(vm.tr("tg_year2"), vm.tr("search_lesson"), "tg2map", extra: "rocnik")
add(vm.tr("tg_year3"), vm.tr("search_lesson"), "tg3map", extra: "rocnik")
add(vm.tr("tg_year4"), vm.tr("search_lesson"), "tg4map", extra: "rocnik")
for l in vm.content.tg {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "tgunit\(l.int("id"))", extra: "autocad")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "tgex\(l.int("id"))")
}
for l in vm.content.tg2 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "tg2unit\(l.int("id"))", extra: "autocad")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "tg2ex\(l.int("id"))")
}
for l in vm.content.tg3 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "tg3unit\(l.int("id"))", extra: "autocad")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "tg3ex\(l.int("id"))")
}
for l in vm.content.tg4 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "tg4unit\(l.int("id"))", extra: "autocad")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "tg4ex\(l.int("id"))")
}
add(vm.tr("Programování"), vm.tr("search_lesson"), "csyears", extra: "csharp c#")
add(vm.tr("cs_year1"), vm.tr("search_lesson"), "csmap", extra: "rocnik")
add(vm.tr("cs_year2"), vm.tr("search_lesson"), "cs2map", extra: "rocnik")
add(vm.tr("cs_year3"), vm.tr("search_lesson"), "cs3map", extra: "rocnik")
add(vm.tr("cs_year4"), vm.tr("search_lesson"), "cs4map", extra: "rocnik")
for l in vm.content.cs {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "csunit\(l.int("id"))", extra: "csharp")
}
for l in vm.content.cs2 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "cs2unit\(l.int("id"))", extra: "csharp")
}
for l in vm.content.cs3 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "cs3unit\(l.int("id"))", extra: "csharp")
}
for l in vm.content.cs4 {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "cs4unit\(l.int("id"))", extra: "csharp")
}
add(vm.tr("Chemie"), vm.tr("search_lesson"), "chemmap", extra: "chemie")
add(vm.tr("Biologie"), vm.tr("search_lesson"), "biomap", extra: "biologie")
for l in vm.content.chem {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "chemunit\(l.int("id"))", extra: "chemie")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "chemex\(l.int("id"))")
}
for l in vm.content.bio {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "biounit\(l.int("id"))", extra: "biologie")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "bioex\(l.int("id"))")
}
add(vm.tr("Mluvnice"), vm.tr("search_lesson"), "mluvnice")
add(vm.tr("Maturitní četba"), vm.tr("search_book"), "readinglist")
for b in vm.content.books {
add(b.str("title"), vm.tr("search_book"), b.str("page"))
}
return out
}
struct RoadmapScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
roadmapPath
#else
roadmapList
#endif
}
#if os(macOS)
private var roadmapPath: some View {
let current = vm.content.german.firstIndex { u in
u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) }
}
let lit = CGFloat(max((current ?? vm.content.german.count) - 1, 0))
return PathMap(
nodes: vm.content.german.enumerated().map { i, u in
let unlocked = u.bool("unlocked")
let names = u.strs("names")
let done = unlocked && !names.isEmpty && names.indices.allSatisfy {
vm.progress.germanDone(u.int("id"), $0 + 1)
}
return MapNode(
id: "unit-\(i)",
label: u.str("title"),
locked: !unlocked,
done: done,
current: i == current,
finish: i == vm.content.german.count - 1,
destination: unlocked ? .unitMap(u.int("id")) : nil
)
},
palette: vm.palette,
litUntil: lit,
nodeSize: MacChrome.pathNode,
mx: 100,
my: 140,
spac: 196,
gap: 280,
wave: 18
)
.padding(28)
.navigationTitle(vm.tr("roadmap_title"))
.navigationSubtitle(vm.tr("roadmap_sub"))
}
#endif
#if os(iOS)
private var roadmapList: some View {
List {
ForEach(Array(vm.content.german.enumerated()), id: \.offset) { _, u in
let unlocked = u.bool("unlocked")
let names = u.strs("names")
let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) }
if unlocked {
NavigationLink(value: Route.unitMap(u.int("id"))) {
Label {
Text(u.str("title"))
} icon: {
Image(systemName: done ? "checkmark.circle.fill" : "circle")
}
}
} else {
Label(u.str("title"), systemImage: "lock.fill")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle(vm.tr("roadmap_title"))
.navigationSubtitle(vm.tr("roadmap_sub"))
.appListStyle()
}
#endif
}
struct UnitMapScreen: View {
@ObservedObject var vm: AppModel
let unitId: Int
var body: some View {
if let u = vm.content.germanUnit(unitId) {
#if os(macOS)
unitPath(u)
#else
unitList(u)
#endif
}
}
#if os(macOS)
private func unitPath(_ u: J) -> some View {
let names = u.strs("names")
let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) }
let lit = CGFloat(next ?? names.count)
let branch: (index: Int, node: MapNode)? = u.has("branch")
? (
1,
MapNode(
id: "vocab-\(unitId)",
label: vm.tr("Vokabeltraining"),
locked: false,
done: vm.progress.vocabDone(unitId),
current: false,
destination: .vocab(unitId),
symbol: "character.book.closed.fill"
)
)
: nil
return PathMap(
nodes: names.enumerated().map { i, name in
MapNode(
id: "e\(i + 1)",
label: name,
locked: false,
done: vm.progress.germanDone(unitId, i + 1),
current: i == next,
destination: .germanEx(unitId, i + 1)
)
},
palette: vm.palette,
litUntil: lit,
nodeSize: MacChrome.pathNode,
mx: 100,
my: 136,
spac: 190,
gap: 268,
wave: 16,
branch: branch
)
.padding(28)
.navigationTitle(u.str("title"))
.navigationSubtitle(vm.tr(u.str("sub")))
}
#endif
#if os(iOS)
private func unitList(_ u: J) -> some View {
let names = u.strs("names")
return List {
ForEach(names.indices, id: \.self) { i in
NavigationLink(value: Route.germanEx(unitId, i + 1)) {
Label {
Text(names[i])
} icon: {
Image(systemName: vm.progress.germanDone(unitId, i + 1) ? "checkmark.circle.fill" : "circle")
}
}
}
if u.has("branch") {
NavigationLink(value: Route.vocab(unitId)) {
Label(vm.tr("Vokabeltraining"), systemImage: "character.book.closed")
}
}
}
.navigationTitle(u.str("title"))
.navigationSubtitle(vm.tr(u.str("sub")))
.appListStyle()
}
#endif
}
struct NetYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("net_year1", "net_sub", .netMap),
("net_year2", "net2_sub", .net2Map),
("net_year3", "net3_sub", .net3Map),
("net_year4", "net4_sub", .net4Map),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("net_years_title"))
.navigationSubtitle(vm.tr("net_years_sub"))
#else
List {
NavigationLink(value: Route.netMap) {
ListRowLabel(title: vm.tr("net_year1"), subtitle: vm.tr("net_sub"), progress: routeProgress(vm, Route.netMap))
}
NavigationLink(value: Route.net2Map) {
ListRowLabel(title: vm.tr("net_year2"), subtitle: vm.tr("net2_sub"), progress: routeProgress(vm, Route.net2Map))
}
NavigationLink(value: Route.net3Map) {
ListRowLabel(title: vm.tr("net_year3"), subtitle: vm.tr("net3_sub"), progress: routeProgress(vm, Route.net3Map))
}
NavigationLink(value: Route.net4Map) {
ListRowLabel(title: vm.tr("net_year4"), subtitle: vm.tr("net4_sub"), progress: routeProgress(vm, Route.net4Map))
}
}
.navigationTitle(vm.tr("net_years_title"))
.navigationSubtitle(vm.tr("net_years_sub"))
.appListStyle()
#endif
}
}
struct HwYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("hw_year1", "hw_sub", .hwMap),
("hw_year2", "hw2_sub", .hw2Map),
("hw_year3", "hw3_sub", .hw3Map),
("hw_year4", "hw4_sub", .hw4Map),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Technické vybavení"))
.navigationSubtitle(vm.tr("hw_years_sub"))
#else
List {
NavigationLink(value: Route.hwMap) {
ListRowLabel(title: vm.tr("hw_year1"), subtitle: vm.tr("hw_sub"), progress: routeProgress(vm, Route.hwMap))
}
NavigationLink(value: Route.hw2Map) {
ListRowLabel(title: vm.tr("hw_year2"), subtitle: vm.tr("hw2_sub"), progress: routeProgress(vm, Route.hw2Map))
}
NavigationLink(value: Route.hw3Map) {
ListRowLabel(title: vm.tr("hw_year3"), subtitle: vm.tr("hw3_sub"), progress: routeProgress(vm, Route.hw3Map))
}
NavigationLink(value: Route.hw4Map) {
ListRowLabel(title: vm.tr("hw_year4"), subtitle: vm.tr("hw4_sub"), progress: routeProgress(vm, Route.hw4Map))
}
}
.navigationTitle(vm.tr("Technické vybavení"))
.navigationSubtitle(vm.tr("hw_years_sub"))
.appListStyle()
#endif
}
}
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: 200) { i in
NavigationLink(value: rows[i].3) {
MacLinkCard(title: vm.tr(rows[i].1), subtitle: vm.tr(rows[i].2), badge: rows[i].0, progress: routeProgress(vm, rows[i].3))
}
.buttonStyle(PressScaleStyle())
}
.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"), progress: routeProgress(vm, Route.roadmap))
}
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"), progress: routeProgress(vm, Route.deMap(year)))
}
}
}
.navigationTitle(vm.tr("Deutsch"))
.navigationSubtitle(vm.tr("de_home_sub"))
#endif
}
}
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: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.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"), progress: routeProgress(vm, Route.enMap(year)))
}
}
}
.navigationTitle(vm.tr("English"))
.navigationSubtitle(vm.tr("en_years_sub"))
.appListStyle()
#endif
}
}
struct OnYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("on_year1", "on_sub", .onMap),
("on_year2", "on2_sub", .on2Map),
("on_year3", "on3_sub", .on3Map),
("on_year4", "on4_sub", .on4Map),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Občanská nauka"))
.navigationSubtitle(vm.tr("on_years_sub"))
#else
List {
NavigationLink(value: Route.onMap) {
ListRowLabel(title: vm.tr("on_year1"), subtitle: vm.tr("on_sub"), progress: routeProgress(vm, Route.onMap))
}
NavigationLink(value: Route.on2Map) {
ListRowLabel(title: vm.tr("on_year2"), subtitle: vm.tr("on2_sub"), progress: routeProgress(vm, Route.on2Map))
}
NavigationLink(value: Route.on3Map) {
ListRowLabel(title: vm.tr("on_year3"), subtitle: vm.tr("on3_sub"), progress: routeProgress(vm, Route.on3Map))
}
NavigationLink(value: Route.on4Map) {
ListRowLabel(title: vm.tr("on_year4"), subtitle: vm.tr("on4_sub"), progress: routeProgress(vm, Route.on4Map))
}
}
.navigationTitle(vm.tr("Občanská nauka"))
.navigationSubtitle(vm.tr("on_years_sub"))
.appListStyle()
#endif
}
}
struct LessonRow: Identifiable {
let id: String
let title: String
let done: Bool
let destination: Route
}
struct LessonListScreen: View {
@ObservedObject var vm: AppModel
let title: String
let subtitle: String
let rows: [LessonRow]
var body: some View {
#if os(macOS)
lessonPath
#else
lessonList
#endif
}
#if os(macOS)
private var lessonPath: some View {
let next = rows.firstIndex { !$0.done }
return PathMap(
nodes: rows.enumerated().map { i, row in
MapNode(
id: row.id,
label: row.title,
locked: false,
done: row.done,
current: i == next,
destination: row.destination
)
},
palette: vm.palette,
litUntil: CGFloat(next ?? rows.count),
nodeSize: MacChrome.pathNode,
mx: 98,
my: 134,
spac: 186,
gap: 272,
wave: 16
)
.padding(28)
.navigationTitle(title)
.navigationSubtitle(subtitle)
}
#endif
#if os(iOS)
private var lessonList: some View {
List(rows) { row in
NavigationLink(value: row.destination) {
Label {
Text(row.title)
} icon: {
Image(systemName: row.done ? "checkmark.circle.fill" : "circle")
}
}
}
.navigationTitle(title)
.navigationSubtitle(subtitle)
.appListStyle()
}
#endif
}
struct SlidesScreen: View {
@ObservedObject var vm: AppModel
let title: String
let subtitle: String
let slides: [J]
let next: Route
@State private var idx = 0
var body: some View {
Group {
#if os(iOS)
TabView(selection: $idx) {
ForEach(slides.indices, id: \.self) { i in
slidePage(slides[i]).tag(i)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
#else
if slides.indices.contains(idx) {
slidePage(slides[idx])
.id(idx)
.transition(.opacity)
}
#endif
}
#if os(macOS)
.animation(Motion.settle, value: idx)
#endif
.detailScreen(title, subtitle: subtitle)
.glassBottomBar {
VStack(spacing: 10) {
HStack(spacing: 6) {
ForEach(slides.indices, id: \.self) { i in
Capsule()
.fill(i == idx ? Color.accentColor : Color(.tertiarySystemFill))
.frame(width: i == idx ? 18 : 6, height: 6)
}
}
.animation(Motion.settle, value: idx)
HStack(spacing: 10) {
if idx > 0 {
Button {
withAnimation { idx -= 1 }
} label: {
Image(systemName: "chevron.left")
.frame(minWidth: 24)
}
#if os(macOS)
.buttonStyle(.bordered)
#else
.buttonStyle(.glass)
#endif
.controlSize(.large)
}
if idx < slides.count - 1 {
PrimaryButton(label: vm.tr("net_slide_next")) {
withAnimation { idx += 1 }
}
} else {
PrimaryButton(label: vm.tr("net_slide_start")) {
vm.go(next)
}
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
#if os(macOS)
.macPage(MacChrome.actionWidth)
#endif
}
}
private func slidePage(_ slide: J) -> some View {
ScrollView {
SlideBody(slide: slide)
.padding(.horizontal, slideInset)
.padding(.top, 22)
.padding(.bottom, 28)
.frame(maxWidth: 860)
.frame(maxWidth: .infinity, alignment: .top)
}
}
private var slideInset: CGFloat {
#if os(macOS)
MacChrome.pageInset
#else
16
#endif
}
}
/// One explanation slide: label, title, tip and numbered points.
struct SlideBody: View {
let slide: J
var body: some View {
slideStack(slide)
}
private func slideStack(_ slide: J) -> some View {
VStack(alignment: .leading, spacing: 18) {
Text(slide.str("kicker"))
#if os(macOS)
.font(.subheadline.weight(.semibold))
#else
.font(.caption.weight(.semibold))
#endif
.foregroundStyle(Color.accentColor)
Text(slide.str("title"))
#if os(macOS)
.font(.title.bold())
#else
.font(.title2.bold())
#endif
.fixedSize(horizontal: false, vertical: true)
if let tip = slide.strOrNull("tip") {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "lightbulb.fill")
.foregroundStyle(Color.accentColor)
.padding(.top, 2)
Text(tip)
#if os(macOS)
.font(.body)
#else
.font(.subheadline)
#endif
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
slideLines(slide.strs("lines"))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func slideLines(_ lines: [String]) -> some View {
let points = studyPoints(lines)
return VStack(alignment: .leading, spacing: 10) {
ForEach(Array(points.enumerated()), id: \.offset) { _, point in
HStack(alignment: .top, spacing: 14) {
Text(point.0)
.font(.callout.weight(.bold))
.foregroundStyle(Color.accentColor)
.frame(minWidth: 28, minHeight: 28)
.background(Color.accentColor.opacity(0.14), in: Circle())
Text(point.1)
#if os(macOS)
.font(.title3)
#else
.font(.body)
#endif
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 3)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.primary.opacity(0.045))
}
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
}
}
}
}
/// Lines that end in the middle of a sentence stay one point. A leading
/// "1. " is the point number, not part of the sentence.
private func studyPoints(_ lines: [String]) -> [(String, String)] {
var chunks: [String] = []
var buffer = ""
func flush() {
let text = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty { chunks.append(text) }
buffer = ""
}
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
if buffer.isEmpty {
buffer = trimmed
} else if continuesStudyPoint(buffer) {
buffer += " " + trimmed
} else {
flush()
buffer = trimmed
}
}
flush()
return chunks.enumerated().map { index, chunk in
let pattern = #"^(\d+)\.\s+(.*)$"#
if let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: chunk, range: NSRange(chunk.startIndex..., in: chunk)),
let number = Range(match.range(at: 1), in: chunk),
let rest = Range(match.range(at: 2), in: chunk),
!continuesStudyPoint(String(chunk[rest])) {
return (String(chunk[number]), String(chunk[rest]))
}
return (String(index + 1), chunk)
}
}
private func continuesStudyPoint(_ text: String) -> Bool {
guard let last = text.last else { return false }
return last == "," || last == ":" || last == ";" || last == "–" || last == "-" || last == "("
}
}
struct LitYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("lit_year1", "lit_sub", .litMap),
("lit_year2", "lit2_sub", .lit2Map),
("lit_year3", "lit3_sub", .lit3Map),
("lit_year4", "lit4_sub", .lit4Map),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Literatura"))
.navigationSubtitle(vm.tr("lit_years_sub"))
#else
List {
NavigationLink(value: Route.litMap) {
ListRowLabel(title: vm.tr("lit_year1"), subtitle: vm.tr("lit_sub"), progress: routeProgress(vm, Route.litMap))
}
NavigationLink(value: Route.lit2Map) {
ListRowLabel(title: vm.tr("lit_year2"), subtitle: vm.tr("lit2_sub"), progress: routeProgress(vm, Route.lit2Map))
}
NavigationLink(value: Route.lit3Map) {
ListRowLabel(title: vm.tr("lit_year3"), subtitle: vm.tr("lit3_sub"), progress: routeProgress(vm, Route.lit3Map))
}
NavigationLink(value: Route.lit4Map) {
ListRowLabel(title: vm.tr("lit_year4"), subtitle: vm.tr("lit4_sub"), progress: routeProgress(vm, Route.lit4Map))
}
}
.navigationTitle(vm.tr("Literatura"))
.navigationSubtitle(vm.tr("lit_years_sub"))
.appListStyle()
#endif
}
}
struct MatYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route, String)] = [
("mat0_year", "mat0_sub", .mat0Map, "ZŠ"),
("mat_year1", "mat_sub", .matMap, "1."),
("mat_year2", "mat2_sub", .mat2Map, "2."),
("mat_year3", "mat3_sub", .mat3Map, "3."),
("mat_year4", "mat4_sub", .mat4Map, "4."),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: years[i].3, progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Matematika"))
.navigationSubtitle(vm.tr("mat_years_sub"))
#else
List {
NavigationLink(value: Route.mat0Map) {
ListRowLabel(title: vm.tr("mat0_year"), subtitle: vm.tr("mat0_sub"), progress: routeProgress(vm, Route.mat0Map))
}
NavigationLink(value: Route.matMap) {
ListRowLabel(title: vm.tr("mat_year1"), subtitle: vm.tr("mat_sub"), progress: routeProgress(vm, Route.matMap))
}
NavigationLink(value: Route.mat2Map) {
ListRowLabel(title: vm.tr("mat_year2"), subtitle: vm.tr("mat2_sub"), progress: routeProgress(vm, Route.mat2Map))
}
NavigationLink(value: Route.mat3Map) {
ListRowLabel(title: vm.tr("mat_year3"), subtitle: vm.tr("mat3_sub"), progress: routeProgress(vm, Route.mat3Map))
}
NavigationLink(value: Route.mat4Map) {
ListRowLabel(title: vm.tr("mat_year4"), subtitle: vm.tr("mat4_sub"), progress: routeProgress(vm, Route.mat4Map))
}
}
.navigationTitle(vm.tr("Matematika"))
.navigationSubtitle(vm.tr("mat_years_sub"))
.appListStyle()
#endif
}
}
struct CsYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("cs_year1", "cs_sub", .csMap),
("cs_year2", "cs2_sub", .cs2Map),
("cs_year3", "cs3_sub", .cs3Map),
("cs_year4", "cs4_sub", .cs4Map),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Programování"))
.navigationSubtitle(vm.tr("cs_years_sub"))
#else
List {
NavigationLink(value: Route.csMap) {
ListRowLabel(title: vm.tr("cs_year1"), subtitle: vm.tr("cs_sub"), progress: routeProgress(vm, Route.csMap))
}
NavigationLink(value: Route.cs2Map) {
ListRowLabel(title: vm.tr("cs_year2"), subtitle: vm.tr("cs2_sub"), progress: routeProgress(vm, Route.cs2Map))
}
NavigationLink(value: Route.cs3Map) {
ListRowLabel(title: vm.tr("cs_year3"), subtitle: vm.tr("cs3_sub"), progress: routeProgress(vm, Route.cs3Map))
}
NavigationLink(value: Route.cs4Map) {
ListRowLabel(title: vm.tr("cs_year4"), subtitle: vm.tr("cs4_sub"), progress: routeProgress(vm, Route.cs4Map))
}
}
.navigationTitle(vm.tr("Programování"))
.navigationSubtitle(vm.tr("cs_years_sub"))
.appListStyle()
#endif
}
}
struct PgYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("pg_year1", "pg_sub", .pgMap),
("pg_year2", "pg2_sub", .pg2Map),
("pg_year3", "pg3_sub", .pg3Map),
("pg_year4", "pg4_sub", .pg4Map),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Prezentační grafika"))
.navigationSubtitle(vm.tr("pg_years_sub"))
#else
List {
NavigationLink(value: Route.pgMap) {
ListRowLabel(title: vm.tr("pg_year1"), subtitle: vm.tr("pg_sub"), progress: routeProgress(vm, Route.pgMap))
}
NavigationLink(value: Route.pg2Map) {
ListRowLabel(title: vm.tr("pg_year2"), subtitle: vm.tr("pg2_sub"), progress: routeProgress(vm, Route.pg2Map))
}
NavigationLink(value: Route.pg3Map) {
ListRowLabel(title: vm.tr("pg_year3"), subtitle: vm.tr("pg3_sub"), progress: routeProgress(vm, Route.pg3Map))
}
NavigationLink(value: Route.pg4Map) {
ListRowLabel(title: vm.tr("pg_year4"), subtitle: vm.tr("pg4_sub"), progress: routeProgress(vm, Route.pg4Map))
}
}
.navigationTitle(vm.tr("Prezentační grafika"))
.navigationSubtitle(vm.tr("pg_years_sub"))
.appListStyle()
#endif
}
}
struct TgYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("tg_year1", "tg_sub", .tgMap),
("tg_year2", "tg2_sub", .tg2Map),
("tg_year3", "tg3_sub", .tg3Map),
("tg_year4", "tg4_sub", .tg4Map),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Technická grafika"))
.navigationSubtitle(vm.tr("tg_years_sub"))
#else
List {
NavigationLink(value: Route.tgMap) {
ListRowLabel(title: vm.tr("tg_year1"), subtitle: vm.tr("tg_sub"), progress: routeProgress(vm, Route.tgMap))
}
NavigationLink(value: Route.tg2Map) {
ListRowLabel(title: vm.tr("tg_year2"), subtitle: vm.tr("tg2_sub"), progress: routeProgress(vm, Route.tg2Map))
}
NavigationLink(value: Route.tg3Map) {
ListRowLabel(title: vm.tr("tg_year3"), subtitle: vm.tr("tg3_sub"), progress: routeProgress(vm, Route.tg3Map))
}
NavigationLink(value: Route.tg4Map) {
ListRowLabel(title: vm.tr("tg_year4"), subtitle: vm.tr("tg4_sub"), progress: routeProgress(vm, Route.tg4Map))
}
}
.navigationTitle(vm.tr("Technická grafika"))
.navigationSubtitle(vm.tr("tg_years_sub"))
.appListStyle()
#endif
}
}
struct FyzYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("fyz_year1", "fyz_sub", .fyzMap),
("fyz_year2", "fyz2_sub", .fyz2Map),
("fyz_year3", "fyz3_sub", .fyz3Map),
("fyz_year4", "fyz4_sub", .fyz4Map),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2))
}
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Fyzika"))
.navigationSubtitle(vm.tr("fyz_years_sub"))
#else
List {
NavigationLink(value: Route.fyzMap) {
ListRowLabel(title: vm.tr("fyz_year1"), subtitle: vm.tr("fyz_sub"), progress: routeProgress(vm, Route.fyzMap))
}
NavigationLink(value: Route.fyz2Map) {
ListRowLabel(title: vm.tr("fyz_year2"), subtitle: vm.tr("fyz2_sub"), progress: routeProgress(vm, Route.fyz2Map))
}
NavigationLink(value: Route.fyz3Map) {
ListRowLabel(title: vm.tr("fyz_year3"), subtitle: vm.tr("fyz3_sub"), progress: routeProgress(vm, Route.fyz3Map))
}
NavigationLink(value: Route.fyz4Map) {
ListRowLabel(title: vm.tr("fyz_year4"), subtitle: vm.tr("fyz4_sub"), progress: routeProgress(vm, Route.fyz4Map))
}
}
.navigationTitle(vm.tr("Fyzika"))
.navigationSubtitle(vm.tr("fyz_years_sub"))
.appListStyle()
#endif
}
}
struct SciMapScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
MacFillGrid(count: 2, minWidth: 260, minHeight: 200) { i in
if i == 0 {
NavigationLink(value: Route.chemMap) {
MacLinkCard(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub"), systemImage: "flask.fill", progress: routeProgress(vm, .chemMap))
}
.buttonStyle(PressScaleStyle())
} else {
NavigationLink(value: Route.bioMap) {
MacLinkCard(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub"), systemImage: "leaf.fill", progress: routeProgress(vm, .bioMap))
}
.buttonStyle(PressScaleStyle())
}
}
.navigationTitle(vm.tr("Základy přírodních věd"))
.navigationSubtitle(vm.tr("sci_sub"))
#else
List {
NavigationLink(value: Route.chemMap) {
Label {
ListRowLabel(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub"), progress: routeProgress(vm, .chemMap))
} icon: {
Image(systemName: "flask.fill")
}
}
NavigationLink(value: Route.bioMap) {
Label {
ListRowLabel(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub"), progress: routeProgress(vm, .bioMap))
} icon: {
Image(systemName: "leaf.fill")
}
}
}
.navigationTitle(vm.tr("Základy přírodních věd"))
.navigationSubtitle(vm.tr("sci_sub"))
.appListStyle()
#endif
}
}
struct CzechMapScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
MacFillGrid(count: 3, minWidth: 260, minHeight: 200) { i in
switch i {
case 1:
NavigationLink(value: Route.mluvnice) {
MacLinkCard(title: vm.tr("Mluvnice"), subtitle: vm.tr("mluv_sub"), systemImage: "textformat", progress: routeProgress(vm, .mluvnice))
}
.buttonStyle(PressScaleStyle())
case 2:
NavigationLink(value: Route.readingList) {
MacLinkCard(title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub"), systemImage: "books.vertical")
}
.buttonStyle(PressScaleStyle())
default:
NavigationLink(value: Route.litYears) {
MacLinkCard(title: vm.tr("Literatura"), subtitle: vm.tr("lit_years_sub"), systemImage: "book.closed", progress: routeProgress(vm, .litYears))
}
.buttonStyle(PressScaleStyle())
}
}
.navigationTitle(vm.tr("Český jazyk a literatura"))
.navigationSubtitle(vm.tr("czech_sub"))
#else
List {
NavigationLink(value: Route.litYears) {
Label {
ListRowLabel(title: vm.tr("Literatura"), subtitle: vm.tr("lit_years_sub"), progress: routeProgress(vm, .litYears))
} icon: {
Image(systemName: "book.closed")
}
}
NavigationLink(value: Route.mluvnice) {
Label {
ListRowLabel(title: vm.tr("Mluvnice"), subtitle: vm.tr("mluv_sub"), progress: routeProgress(vm, .mluvnice))
} icon: {
Image(systemName: "textformat")
}
}
NavigationLink(value: Route.readingList) {
Label {
ListRowLabel(title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub"))
} icon: {
Image(systemName: "books.vertical")
}
}
}
.navigationTitle(vm.tr("Český jazyk a literatura"))
.navigationSubtitle(vm.tr("czech_sub"))
.appListStyle()
#endif
}
}
struct BookListScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
#if os(macOS)
macBooks
#else
iosBooks
#endif
}
#if os(macOS)
private var macBooks: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: 16)], spacing: 16) {
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
let parts = bookTitleParts(b.str("title"))
let quizDone = !b.arr("quiz").isEmpty && vm.progress.bookQuiz(b.str("id"))
let plotDone = !b.arr("plot").isEmpty && vm.progress.bookPlot(b.str("id"))
NavigationLink(value: Route.book(b.str("id"))) {
VStack(alignment: .leading, spacing: 8) {
Image(systemName: "book.fill")
.font(.title2)
.foregroundStyle(.tint)
Text(parts.title)
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
if let author = parts.author {
Text(author)
.font(.body)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
HStack(spacing: 8) {
let genre = b.str("genre")
Text(genre.isEmpty ? vm.tr(b.str("subKey")) : genre)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
if quizDone || plotDone {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.accessibilityLabel(vm.tr("book_done"))
}
}
}
.padding(18)
.frame(maxWidth: .infinity, minHeight: 168, alignment: .topLeading)
.softSurface(cornerRadius: 18)
}
.buttonStyle(PressScaleStyle())
}
}
.padding(MacChrome.pageInset)
}
.navigationTitle(vm.tr("Maturitní četba"))
.navigationSubtitle(vm.tr("reading_sub"))
}
#endif
#if os(iOS)
private var iosBooks: some View {
List {
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
let parts = bookTitleParts(b.str("title"))
let quizDone = !b.arr("quiz").isEmpty && vm.progress.bookQuiz(b.str("id"))
let plotDone = !b.arr("plot").isEmpty && vm.progress.bookPlot(b.str("id"))
NavigationLink(value: Route.book(b.str("id"))) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "book.fill")
#if os(macOS)
.font(.title2)
#else
.font(.title3)
#endif
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(parts.title)
#if os(macOS)
.font(.title3.weight(.semibold))
#else
.font(.body.weight(.semibold))
#endif
if let author = parts.author {
Text(author)
#if os(macOS)
.font(.body)
#else
.font(.subheadline)
#endif
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
let genre = b.str("genre")
Text(genre.isEmpty ? vm.tr(b.str("subKey")) : genre)
#if os(macOS)
.font(.subheadline)
#else
.font(.caption)
#endif
.foregroundStyle(.secondary)
if quizDone || plotDone {
Label(vm.tr("book_done"), systemImage: "checkmark.circle.fill")
#if os(macOS)
.font(.subheadline.weight(.semibold))
#else
.font(.caption.weight(.semibold))
#endif
.foregroundStyle(.green)
}
}
}
}
#if os(macOS)
.padding(.vertical, 4)
#endif
}
}
}
.navigationTitle(vm.tr("Maturitní četba"))
.navigationSubtitle(vm.tr("reading_sub"))
.appListStyle()
}
#endif
}
struct BookScreen: View {
@ObservedObject var vm: AppModel
let id: String
var body: some View {
if let b = vm.content.book(id) {
let hasQuiz = !b.arr("quiz").isEmpty
let hasPlot = !b.arr("plot").isEmpty
let notes = b.arr("notes")
let quizDone = vm.progress.bookQuiz(id)
let plotDone = vm.progress.bookPlot(id)
let parts = bookTitleParts(b.str("title"))
ScrollView {
VStack(alignment: .leading, spacing: 28) {
GlassCard {
VStack(alignment: .leading, spacing: 10) {
if let author = parts.author {
Text(author)
#if os(macOS)
.font(.title3)
#else
.font(.subheadline)
#endif
.foregroundStyle(.secondary)
}
if !b.str("genre").isEmpty {
Text(b.str("genre"))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.tint)
}
Text(vm.tr("book_study_hint"))
#if os(macOS)
.font(.title3)
#else
.font(.body)
#endif
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
if hasQuiz || hasPlot {
VStack(alignment: .leading, spacing: 14) {
Text(vm.tr("book_practice"))
#if os(macOS)
.font(.title.weight(.semibold))
#else
.font(.title2.weight(.semibold))
#endif
#if os(macOS)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 320), spacing: 16)], spacing: 16) {
if hasQuiz {
NavigationLink(value: Route.bookQuiz(id)) {
BookActionCard(
title: vm.tr("book_quiz_heading"),
subtitle: vm.tr("lit_quiz_sub"),
systemImage: "questionmark.circle.fill",
done: quizDone,
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(PressScaleStyle())
}
if hasPlot {
NavigationLink(value: Route.bookPlot(id)) {
BookActionCard(
title: vm.tr("lit_plot_title"),
subtitle: vm.tr("lit_plot_sub"),
systemImage: "list.number",
done: plotDone,
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(PressScaleStyle())
}
}
#else
if hasQuiz {
NavigationLink(value: Route.bookQuiz(id)) {
BookActionCard(
title: vm.tr("book_quiz_heading"),
subtitle: vm.tr("lit_quiz_sub"),
systemImage: "questionmark.circle.fill",
done: quizDone,
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(PressScaleStyle())
}
if hasPlot {
NavigationLink(value: Route.bookPlot(id)) {
BookActionCard(
title: vm.tr("lit_plot_title"),
subtitle: vm.tr("lit_plot_sub"),
systemImage: "list.number",
done: plotDone,
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(PressScaleStyle())
}
#endif
}
}
if !notes.isEmpty {
VStack(alignment: .leading, spacing: 14) {
Text(vm.tr("book_notes"))
#if os(macOS)
.font(.title.weight(.semibold))
#else
.font(.title2.weight(.semibold))
#endif
#if os(macOS)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 360), spacing: 16)], alignment: .leading, spacing: 16) {
ForEach(Array(notes.enumerated()), id: \.offset) { _, note in
BookNoteCard(note: note)
}
}
#else
ForEach(Array(notes.enumerated()), id: \.offset) { _, note in
BookNoteCard(note: note)
}
#endif
}
}
}
#if os(macOS)
.padding(MacChrome.pageInset)
.frame(maxWidth: .infinity, alignment: .leading)
#else
.padding()
.padding(.bottom, 24)
#endif
}
.navigationTitle(parts.title)
.navigationSubtitle(parts.author ?? vm.tr(b.str("subKey")))
}
}
}
private struct BookActionCard: View {
let title: String
let subtitle: String
let systemImage: String
let done: Bool
let doneLabel: String
var body: some View {
HStack(alignment: .center, spacing: 16) {
Image(systemName: systemImage)
#if os(macOS)
.font(.largeTitle)
#else
.font(.title)
#endif
.foregroundStyle(.tint)
.frame(width: 44)
VStack(alignment: .leading, spacing: 4) {
Text(title)
#if os(macOS)
.font(.title3.weight(.semibold))
#else
.font(.headline)
#endif
.foregroundStyle(.primary)
Text(subtitle)
#if os(macOS)
.font(.body)
#else
.font(.subheadline)
#endif
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
if done {
Image(systemName: "checkmark.circle.fill")
#if os(macOS)
.font(.title2)
#else
.font(.title3)
#endif
.foregroundStyle(.green)
.accessibilityLabel(doneLabel)
} else {
Image(systemName: "chevron.right")
.font(.body.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
#if os(macOS)
.padding(20)
#else
.padding(16)
#endif
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.softSurface(cornerRadius: 18)
}
}
private struct BookNoteCard: View {
let note: J
var body: some View {
GlassCard {
VStack(alignment: .leading, spacing: 12) {
Label(note.str("title"), systemImage: noteSymbol(note.str("icon")))
#if os(macOS)
.font(.title2.weight(.semibold))
#else
.font(.headline)
#endif
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(note.strs("lines").enumerated()), id: \.offset) { _, line in
HStack(alignment: .top, spacing: 10) {
Text("•")
.foregroundStyle(.secondary)
Text(line)
#if os(macOS)
.font(.title3)
#else
.font(.body)
#endif
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
}
}
private func bookTitleParts(_ title: String) -> (author: String?, title: String) {
let separators = [" – ", " — ", " - "]
for sep in separators {
if let range = title.range(of: sep) {
let author = String(title[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
let name = String(title[range.upperBound...]).trimmingCharacters(in: .whitespaces)
if !author.isEmpty && !name.isEmpty {
return (author, name)
}
}
}
return (nil, title)
}
private func noteSymbol(_ icon: String) -> String {
switch icon {
case "book": return "book.fill"
case "globe": return "globe.europe.africa.fill"
case "people": return "person.2.fill"
case "bulb": return "lightbulb.fill"
default: return "text.alignleft"
}
}
struct RouteDestination: View {
@ObservedObject var vm: AppModel
let route: Route
var body: some View {
switch route {
case .welcome, .subjects:
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):
UnitMapScreen(vm: vm, unitId: id)
case .germanEx(let unit, let ex):
GermanExercise(vm: vm, unitId: unit, ex: ex)
case .vocab(let id):
VocabExercise(vm: vm, unitId: id)
case .netYears:
NetYearsScreen(vm: vm)
case .netMap:
LessonListScreen(
vm: vm,
title: vm.tr("net_year1"),
subtitle: vm.tr("net_sub"),
rows: vm.content.netLessons.map { l in
let id = l.int("id")
return LessonRow(
id: "n\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.netDone(id),
destination: .netLesson(id)
)
}
)
case .netLesson(let id):
if let l = vm.content.netLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .netEx(id))
}
case .netEx(let id):
NetQuizScreen(vm: vm, id: id)
case .net2Map:
ItYearList(vm: vm, course: "net2", titleKey: "net_year2", subKey: "net2_sub") { .net2Lesson($0) }
case .net2Lesson(let id):
ItSlides(vm: vm, course: "net2", id: id, next: .net2Ex(id))
case .net2Ex(let id):
ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("net2", id)) { vm.markCourse("net2", id) }
case .net3Map:
ItYearList(vm: vm, course: "net3", titleKey: "net_year3", subKey: "net3_sub") { .net3Lesson($0) }
case .net3Lesson(let id):
ItSlides(vm: vm, course: "net3", id: id, next: .net3Ex(id))
case .net3Ex(let id):
ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("net3", id)) { vm.markCourse("net3", id) }
case .net4Map:
ItYearList(vm: vm, course: "net4", titleKey: "net_year4", subKey: "net4_sub") { .net4Lesson($0) }
case .net4Lesson(let id):
ItSlides(vm: vm, course: "net4", id: id, next: .net4Ex(id))
case .net4Ex(let id):
ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("net4", id)) { vm.markCourse("net4", id) }
case .hwYears:
HwYearsScreen(vm: vm)
case .hwMap:
LessonListScreen(
vm: vm,
title: vm.tr("hw_year1"),
subtitle: vm.tr("hw_sub"),
rows: vm.content.hw.map { l in
let id = l.int("id")
return LessonRow(
id: "h\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.hwDone(id),
destination: .hwLesson(id)
)
}
)
case .hwLesson(let id):
if let l = vm.content.hwLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .hwEx(id))
}
case .hwEx(let id):
HwQuizScreen(vm: vm, id: id)
case .hw2Map:
ItYearList(vm: vm, course: "hw2", titleKey: "hw_year2", subKey: "hw2_sub") { .hw2Lesson($0) }
case .hw2Lesson(let id):
ItSlides(vm: vm, course: "hw2", id: id, next: .hw2Ex(id))
case .hw2Ex(let id):
ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("hw2", id)) { vm.markCourse("hw2", id) }
case .hw3Map:
ItYearList(vm: vm, course: "hw3", titleKey: "hw_year3", subKey: "hw3_sub") { .hw3Lesson($0) }
case .hw3Lesson(let id):
ItSlides(vm: vm, course: "hw3", id: id, next: .hw3Ex(id))
case .hw3Ex(let id):
ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("hw3", id)) { vm.markCourse("hw3", id) }
case .hw4Map:
ItYearList(vm: vm, course: "hw4", titleKey: "hw_year4", subKey: "hw4_sub") { .hw4Lesson($0) }
case .hw4Lesson(let id):
ItSlides(vm: vm, course: "hw4", id: id, next: .hw4Ex(id))
case .hw4Ex(let id):
ChoiceLessonScreen(vm: vm, lesson: vm.content.itLesson("hw4", id)) { vm.markCourse("hw4", 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,
title: vm.tr("on_year1"),
subtitle: vm.tr("on_sub"),
rows: vm.content.on.map { l in
let id = l.int("id")
return LessonRow(
id: "o\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.onDone(id),
destination: .onLesson(id)
)
}
)
case .onLesson(let id):
if let l = vm.content.onLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .onEx(id))
}
case .onEx(let id):
OnQuizScreen(vm: vm, id: id)
case .on2Map:
LessonListScreen(
vm: vm,
title: vm.tr("on_year2"),
subtitle: vm.tr("on2_sub"),
rows: vm.content.on2.map { l in
let id = l.int("id")
return LessonRow(
id: "o2\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.on2Done(id),
destination: .on2Lesson(id)
)
}
)
case .on2Lesson(let id):
if let l = vm.content.on2Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .on2Ex(id))
}
case .on2Ex(let id):
On2QuizScreen(vm: vm, id: id)
case .on3Map:
LessonListScreen(
vm: vm,
title: vm.tr("on_year3"),
subtitle: vm.tr("on3_sub"),
rows: vm.content.on3.map { l in
let id = l.int("id")
return LessonRow(
id: "o3\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.on3Done(id),
destination: .on3Lesson(id)
)
}
)
case .on3Lesson(let id):
if let l = vm.content.on3Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .on3Ex(id))
}
case .on3Ex(let id):
On3QuizScreen(vm: vm, id: id)
case .on4Map:
LessonListScreen(
vm: vm,
title: vm.tr("on_year4"),
subtitle: vm.tr("on4_sub"),
rows: vm.content.on4.map { l in
let id = l.int("id")
return LessonRow(
id: "o4\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.on4Done(id),
destination: .on4Lesson(id)
)
}
)
case .on4Lesson(let id):
if let l = vm.content.on4Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .on4Ex(id))
}
case .on4Ex(let id):
On4QuizScreen(vm: vm, id: id)
case .czechMap:
CzechMapScreen(vm: vm)
case .litYears:
LitYearsScreen(vm: vm)
case .litMap:
LessonListScreen(
vm: vm,
title: vm.tr("lit_year1"),
subtitle: vm.tr("lit_sub"),
rows: vm.content.lit.map { l in
let id = l.int("id")
return LessonRow(
id: "l\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.litDone(id),
destination: .litLesson(id)
)
}
)
case .litLesson(let id):
if let l = vm.content.litLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .litEx(id))
}
case .litEx(let id):
LitYearQuizScreen(vm: vm, year: 1, id: id)
case .lit2Map:
LessonListScreen(
vm: vm,
title: vm.tr("lit_year2"),
subtitle: vm.tr("lit2_sub"),
rows: vm.content.lit2.map { l in
let id = l.int("id")
return LessonRow(
id: "l2\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.lit2Done(id),
destination: .lit2Lesson(id)
)
}
)
case .lit2Lesson(let id):
if let l = vm.content.lit2Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .lit2Ex(id))
}
case .lit2Ex(let id):
LitYearQuizScreen(vm: vm, year: 2, id: id)
case .lit3Map:
LessonListScreen(
vm: vm,
title: vm.tr("lit_year3"),
subtitle: vm.tr("lit3_sub"),
rows: vm.content.lit3.map { l in
let id = l.int("id")
return LessonRow(
id: "l3\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.lit3Done(id),
destination: .lit3Lesson(id)
)
}
)
case .lit3Lesson(let id):
if let l = vm.content.lit3Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .lit3Ex(id))
}
case .lit3Ex(let id):
LitYearQuizScreen(vm: vm, year: 3, id: id)
case .lit4Map:
LessonListScreen(
vm: vm,
title: vm.tr("lit_year4"),
subtitle: vm.tr("lit4_sub"),
rows: vm.content.lit4.map { l in
let id = l.int("id")
return LessonRow(
id: "l4\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.lit4Done(id),
destination: .lit4Lesson(id)
)
}
)
case .lit4Lesson(let id):
if let l = vm.content.lit4Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .lit4Ex(id))
}
case .lit4Ex(let id):
LitYearQuizScreen(vm: vm, year: 4, id: id)
case .sciMap:
SciMapScreen(vm: vm)
case .chemMap:
LessonListScreen(
vm: vm,
title: vm.tr("Chemie"),
subtitle: vm.tr("chem_sub"),
rows: vm.content.chem.map { l in
let id = l.int("id")
return LessonRow(
id: "c\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.chemDone(id),
destination: .chemLesson(id)
)
}
)
case .chemLesson(let id):
if let l = vm.content.chemLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .chemEx(id))
}
case .chemEx(let id):
SciQuizScreen(vm: vm, area: "chem", id: id)
case .bioMap:
LessonListScreen(
vm: vm,
title: vm.tr("Biologie"),
subtitle: vm.tr("bio_sub"),
rows: vm.content.bio.map { l in
let id = l.int("id")
return LessonRow(
id: "b\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.bioDone(id),
destination: .bioLesson(id)
)
}
)
case .bioLesson(let id):
if let l = vm.content.bioLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .bioEx(id))
}
case .bioEx(let id):
SciQuizScreen(vm: vm, area: "bio", id: id)
case .matYears:
MatYearsScreen(vm: vm)
case .mat0Map:
LessonListScreen(
vm: vm,
title: vm.tr("mat0_year"),
subtitle: vm.tr("mat0_sub"),
rows: vm.content.mat0.map { l in
let id = l.int("id")
return LessonRow(id: "m0\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.mat0Done(id), destination: .mat0Lesson(id))
}
)
case .mat0Lesson(let id):
if let l = vm.content.mat0Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .mat0Ex(id))
}
case .mat0Ex(let id):
MathPracticeScreen(vm: vm, year: 0, id: id)
case .matMap:
LessonListScreen(
vm: vm,
title: vm.tr("mat_year1"),
subtitle: vm.tr("mat_sub"),
rows: vm.content.mat.map { l in
let id = l.int("id")
return LessonRow(id: "m1\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.matDone(id), destination: .matLesson(id))
}
)
case .matLesson(let id):
if let l = vm.content.matLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .matEx(id))
}
case .matEx(let id):
MathPracticeScreen(vm: vm, year: 1, id: id)
case .mat2Map:
LessonListScreen(
vm: vm,
title: vm.tr("mat_year2"),
subtitle: vm.tr("mat2_sub"),
rows: vm.content.mat2.map { l in
let id = l.int("id")
return LessonRow(id: "m2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.mat2Done(id), destination: .mat2Lesson(id))
}
)
case .mat2Lesson(let id):
if let l = vm.content.mat2Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .mat2Ex(id))
}
case .mat2Ex(let id):
MathPracticeScreen(vm: vm, year: 2, id: id)
case .mat3Map:
LessonListScreen(
vm: vm,
title: vm.tr("mat_year3"),
subtitle: vm.tr("mat3_sub"),
rows: vm.content.mat3.map { l in
let id = l.int("id")
return LessonRow(id: "m3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.mat3Done(id), destination: .mat3Lesson(id))
}
)
case .mat3Lesson(let id):
if let l = vm.content.mat3Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .mat3Ex(id))
}
case .mat3Ex(let id):
MathPracticeScreen(vm: vm, year: 3, id: id)
case .mat4Map:
LessonListScreen(
vm: vm,
title: vm.tr("mat_year4"),
subtitle: vm.tr("mat4_sub"),
rows: vm.content.mat4.map { l in
let id = l.int("id")
return LessonRow(id: "m4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.mat4Done(id), destination: .mat4Lesson(id))
}
)
case .mat4Lesson(let id):
if let l = vm.content.mat4Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .mat4Ex(id))
}
case .mat4Ex(let id):
MathPracticeScreen(vm: vm, year: 4, id: id)
case .fyzYears:
FyzYearsScreen(vm: vm)
case .fyzMap:
LessonListScreen(
vm: vm,
title: vm.tr("fyz_year1"),
subtitle: vm.tr("fyz_sub"),
rows: vm.content.fyz.map { l in
let id = l.int("id")
return LessonRow(
id: "f\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.fyzDone(id),
destination: .fyzLesson(id)
)
}
)
case .fyzLesson(let id):
if let l = vm.content.fyzLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .fyzEx(id))
}
case .fyzEx(let id):
FyzQuizScreen(vm: vm, year: 1, id: id)
case .fyz2Map:
LessonListScreen(
vm: vm,
title: vm.tr("fyz_year2"),
subtitle: vm.tr("fyz2_sub"),
rows: vm.content.fyz2.map { l in
let id = l.int("id")
return LessonRow(
id: "f2\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.fyz2Done(id),
destination: .fyz2Lesson(id)
)
}
)
case .fyz2Lesson(let id):
if let l = vm.content.fyz2Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .fyz2Ex(id))
}
case .fyz2Ex(let id):
FyzQuizScreen(vm: vm, year: 2, id: id)
case .fyz3Map:
LessonListScreen(
vm: vm,
title: vm.tr("fyz_year3"),
subtitle: vm.tr("fyz3_sub"),
rows: vm.content.fyz3.map { l in
let id = l.int("id")
return LessonRow(
id: "f3\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.fyz3Done(id),
destination: .fyz3Lesson(id)
)
}
)
case .fyz3Lesson(let id):
if let l = vm.content.fyz3Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .fyz3Ex(id))
}
case .fyz3Ex(let id):
FyzQuizScreen(vm: vm, year: 3, id: id)
case .fyz4Map:
LessonListScreen(
vm: vm,
title: vm.tr("fyz_year4"),
subtitle: vm.tr("fyz4_sub"),
rows: vm.content.fyz4.map { l in
let id = l.int("id")
return LessonRow(
id: "f4\(id)",
title: vm.tr(l.str("titleKey")),
done: vm.progress.fyz4Done(id),
destination: .fyz4Lesson(id)
)
}
)
case .fyz4Lesson(let id):
if let l = vm.content.fyz4Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .fyz4Ex(id))
}
case .fyz4Ex(let id):
FyzQuizScreen(vm: vm, year: 4, id: id)
case .csYears:
CsYearsScreen(vm: vm)
case .csMap:
LessonListScreen(
vm: vm,
title: vm.tr("cs_year1"),
subtitle: vm.tr("cs_sub"),
rows: vm.content.cs.map { l in
let id = l.int("id")
return LessonRow(id: "c\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.csDone(id), destination: .csLesson(id))
}
)
case .csLesson(let id):
if let l = vm.content.csLesson(id) {
CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs(id) })
}
case .cs2Map:
LessonListScreen(
vm: vm,
title: vm.tr("cs_year2"),
subtitle: vm.tr("cs2_sub"),
rows: vm.content.cs2.map { l in
let id = l.int("id")
return LessonRow(id: "c2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.cs2Done(id), destination: .cs2Lesson(id))
}
)
case .cs2Lesson(let id):
if let l = vm.content.cs2Lesson(id) {
CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs2(id) })
}
case .cs3Map:
LessonListScreen(
vm: vm,
title: vm.tr("cs_year3"),
subtitle: vm.tr("cs3_sub"),
rows: vm.content.cs3.map { l in
let id = l.int("id")
return LessonRow(id: "c3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.cs3Done(id), destination: .cs3Lesson(id))
}
)
case .cs3Lesson(let id):
if let l = vm.content.cs3Lesson(id) {
CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs3(id) })
}
case .cs4Map:
LessonListScreen(
vm: vm,
title: vm.tr("cs_year4"),
subtitle: vm.tr("cs4_sub"),
rows: vm.content.cs4.map { l in
let id = l.int("id")
return LessonRow(id: "c4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.cs4Done(id), destination: .cs4Lesson(id))
}
)
case .cs4Lesson(let id):
if let l = vm.content.cs4Lesson(id) {
CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs4(id) })
}
case .pgYears:
PgYearsScreen(vm: vm)
case .pgMap:
LessonListScreen(
vm: vm,
title: vm.tr("pg_year1"),
subtitle: vm.tr("pg_sub"),
rows: vm.content.pg.map { l in
let id = l.int("id")
return LessonRow(id: "p\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.pgDone(id), destination: .pgLesson(id))
}
)
case .pgLesson(let id):
if let l = vm.content.pgLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .pgEx(id))
}
case .pgEx(let id):
PgQuizScreen(vm: vm, year: 1, id: id)
case .pg2Map:
LessonListScreen(
vm: vm,
title: vm.tr("pg_year2"),
subtitle: vm.tr("pg2_sub"),
rows: vm.content.pg2.map { l in
let id = l.int("id")
return LessonRow(id: "p2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.pg2Done(id), destination: .pg2Lesson(id))
}
)
case .pg2Lesson(let id):
if let l = vm.content.pg2Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .pg2Ex(id))
}
case .pg2Ex(let id):
PgQuizScreen(vm: vm, year: 2, id: id)
case .pg3Map:
LessonListScreen(
vm: vm,
title: vm.tr("pg_year3"),
subtitle: vm.tr("pg3_sub"),
rows: vm.content.pg3.map { l in
let id = l.int("id")
return LessonRow(id: "p3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.pg3Done(id), destination: .pg3Lesson(id))
}
)
case .pg3Lesson(let id):
if let l = vm.content.pg3Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .pg3Ex(id))
}
case .pg3Ex(let id):
PgQuizScreen(vm: vm, year: 3, id: id)
case .pg4Map:
LessonListScreen(
vm: vm,
title: vm.tr("pg_year4"),
subtitle: vm.tr("pg4_sub"),
rows: vm.content.pg4.map { l in
let id = l.int("id")
return LessonRow(id: "p4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.pg4Done(id), destination: .pg4Lesson(id))
}
)
case .pg4Lesson(let id):
if let l = vm.content.pg4Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .pg4Ex(id))
}
case .pg4Ex(let id):
PgQuizScreen(vm: vm, year: 4, id: id)
case .tgYears:
TgYearsScreen(vm: vm)
case .tgMap:
LessonListScreen(
vm: vm,
title: vm.tr("tg_year1"),
subtitle: vm.tr("tg_sub"),
rows: vm.content.tg.map { l in
let id = l.int("id")
return LessonRow(id: "t\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.tgDone(id), destination: .tgLesson(id))
}
)
case .tgLesson(let id):
if let l = vm.content.tgLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .tgEx(id))
}
case .tgEx(let id):
TgQuizScreen(vm: vm, year: 1, id: id)
case .tg2Map:
LessonListScreen(
vm: vm,
title: vm.tr("tg_year2"),
subtitle: vm.tr("tg2_sub"),
rows: vm.content.tg2.map { l in
let id = l.int("id")
return LessonRow(id: "t2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.tg2Done(id), destination: .tg2Lesson(id))
}
)
case .tg2Lesson(let id):
if let l = vm.content.tg2Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .tg2Ex(id))
}
case .tg2Ex(let id):
TgQuizScreen(vm: vm, year: 2, id: id)
case .tg3Map:
LessonListScreen(
vm: vm,
title: vm.tr("tg_year3"),
subtitle: vm.tr("tg3_sub"),
rows: vm.content.tg3.map { l in
let id = l.int("id")
return LessonRow(id: "t3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.tg3Done(id), destination: .tg3Lesson(id))
}
)
case .tg3Lesson(let id):
if let l = vm.content.tg3Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .tg3Ex(id))
}
case .tg3Ex(let id):
TgQuizScreen(vm: vm, year: 3, id: id)
case .tg4Map:
LessonListScreen(
vm: vm,
title: vm.tr("tg_year4"),
subtitle: vm.tr("tg4_sub"),
rows: vm.content.tg4.map { l in
let id = l.int("id")
return LessonRow(id: "t4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.tg4Done(id), destination: .tg4Lesson(id))
}
)
case .tg4Lesson(let id):
if let l = vm.content.tg4Lesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .tg4Ex(id))
}
case .tg4Ex(let id):
TgQuizScreen(vm: vm, year: 4, id: id)
case .mluvnice:
LessonListScreen(
vm: vm,
title: vm.tr("Mluvnice"),
subtitle: vm.tr("mluv_sub"),
rows: vm.content.mluvnice.map { m in
let n = m.int("id")
return LessonRow(
id: "m\(n)",
title: m.str("name"),
done: vm.progress.mluvDone(n),
destination: .mluvEx(n)
)
}
)
case .mluvEx(let n):
MluvExercise(vm: vm, n: n)
case .readingList:
BookListScreen(vm: vm)
case .book(let id):
BookScreen(vm: vm, id: id)
case .bookQuiz(let id):
LitQuizScreen(vm: vm, id: id)
case .bookPlot(let id):
PlotScreen(vm: vm, id: id)
}
}
}
struct CodeLessonScreen: View {
@ObservedObject var vm: AppModel
let lesson: J
let onSuccess: () -> Void
@State private var code = ""
@State private var stdin = ""
@State private var output = ""
@State private var status = ""
private var running: Bool { status == "running" }
private var needsInput: Bool { code.contains("ReadLine") }
var body: some View {
GeometryReader { geo in
if geo.size.width >= 900 {
HStack(alignment: .top, spacing: 28) {
ScrollView {
explanation
.padding(.vertical, 4)
}
.frame(width: min(560, geo.size.width * 0.4))
workspace(height: geo.size.height)
}
.padding(.horizontal, 28)
.padding(.vertical, 20)
.frame(maxWidth: 1640)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
explanation
editorCard.frame(height: 320)
if needsInput { inputField }
outputCard.frame(height: 220)
}
.padding(16)
}
}
}
.detailScreen(vm.tr(lesson.str("titleKey")), subtitle: vm.tr(lesson.str("subKey")))
.onAppear {
if code.isEmpty { code = lesson.str("code") }
}
}
private var explanation: some View {
VStack(alignment: .leading, spacing: 34) {
ForEach(Array(lesson.arr("slides").enumerated()), id: \.offset) { _, slide in
SlideBody(slide: slide)
}
}
}
private func workspace(height: CGFloat) -> some View {
VStack(spacing: 16) {
editorCard
.frame(maxHeight: .infinity)
if needsInput { inputField }
outputCard
.frame(height: max(180, height * 0.32))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var editorCard: some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.foregroundStyle(Color.accentColor)
Text("Program.cs")
.font(.headline.monospaced())
Spacer(minLength: 8)
Button {
code = lesson.str("code")
output = ""
status = ""
} label: {
Label(vm.tr("cs_reset"), systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.disabled(running)
Button { run() } label: {
Label(vm.tr("cs_run"), systemImage: "play.fill")
.padding(.horizontal, 4)
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.return, modifiers: .command)
.help("⌘↩")
.disabled(running)
}
#if os(macOS)
.controlSize(.large)
#endif
.padding(.horizontal, 14)
.padding(.vertical, 10)
Divider().opacity(0.5)
editorField
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(codeFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(.separator.opacity(0.6), lineWidth: 0.5)
}
}
@ViewBuilder
private var editorField: some View {
#if os(macOS)
CodeEditor(text: $code)
#else
TextEditor(text: $code)
.font(.system(size: 15, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.scrollContentBackground(.hidden)
.padding(8)
#endif
}
private var inputField: some View {
VStack(alignment: .leading, spacing: 6) {
Label(vm.tr("cs_stdin"), systemImage: "keyboard")
.font(.subheadline)
.foregroundStyle(.secondary)
TextField("", text: $stdin, axis: .vertical)
.font(.system(size: 15, design: .monospaced))
.lineLimit(1...4)
.textFieldStyle(.roundedBorder)
}
}
private var outputCard: some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
Image(systemName: "terminal")
.foregroundStyle(.secondary)
Text(vm.tr("cs_output"))
.font(.headline)
Spacer(minLength: 8)
statusBadge
}
.padding(.horizontal, 14)
.frame(height: 44)
Divider().opacity(0.5)
ScrollView {
Text(output.isEmpty ? vm.tr("cs_empty") : output)
.font(.system(size: 15, design: .monospaced))
.foregroundStyle(output.isEmpty || running ? .secondary : .primary)
.frame(maxWidth: .infinity, alignment: .topLeading)
.textSelection(.enabled)
.padding(14)
}
}
.background(codeFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(.separator.opacity(0.6), lineWidth: 0.5)
}
}
@ViewBuilder
private var statusBadge: some View {
switch status {
case "running":
ProgressView().controlSize(.small)
case "ok":
Label(vm.tr("cs_done"), systemImage: "checkmark.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(vm.palette.success)
case "fail", "timeout":
Label(vm.tr("cs_failed"), systemImage: "xmark.octagon.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(vm.palette.error)
default:
EmptyView()
}
}
private var codeFill: AnyShapeStyle {
#if os(macOS)
AnyShapeStyle(.background.secondary)
#else
AnyShapeStyle(Color(.secondarySystemBackground))
#endif
}
private func run() {
guard !running else { return }
status = "running"
output = vm.tr("cs_running")
CSharpRunner.run(code, stdin: needsInput ? stdin : "") { result, text in
status = result
switch result {
case "unavailable":
output = vm.tr("cs_unavailable")
case "timeout":
output = (text + "\n\n" + vm.tr("cs_timeout")).trimmingCharacters(in: .whitespacesAndNewlines)
default:
output = text.isEmpty ? vm.tr("cs_empty") : text
}
if result == "ok" { onSuccess() }
}
}
}
#if os(macOS)
/// Plain-text editor for C#: monospaced, coloured, and without smart quotes or
/// autocorrect, which would turn "text" into “text” and break the program.
struct CodeEditor: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator { Coordinator(text: $text) }
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSTextView.scrollableTextView()
scroll.drawsBackground = false
scroll.autohidesScrollers = true
guard let view = scroll.documentView as? NSTextView else { return scroll }
view.delegate = context.coordinator
view.isRichText = false
view.allowsUndo = true
view.isAutomaticQuoteSubstitutionEnabled = false
view.isAutomaticDashSubstitutionEnabled = false
view.isAutomaticTextReplacementEnabled = false
view.isAutomaticSpellingCorrectionEnabled = false
view.isContinuousSpellCheckingEnabled = false
view.isGrammarCheckingEnabled = false
view.isAutomaticLinkDetectionEnabled = false
view.smartInsertDeleteEnabled = false
view.drawsBackground = false
view.font = CSharpStyle.font
view.textContainerInset = NSSize(width: 10, height: 12)
view.typingAttributes = CSharpStyle.base
view.string = text
CSharpStyle.highlight(view.textStorage)
return scroll
}
func updateNSView(_ scroll: NSScrollView, context: Context) {
context.coordinator.text = $text
guard let view = scroll.documentView as? NSTextView, view.string != text else { return }
view.string = text
CSharpStyle.highlight(view.textStorage)
}
final class Coordinator: NSObject, NSTextViewDelegate {
var text: Binding<String>
init(text: Binding<String>) {
self.text = text
}
func textDidChange(_ notification: Notification) {
guard let view = notification.object as? NSTextView else { return }
text.wrappedValue = view.string
CSharpStyle.highlight(view.textStorage)
view.typingAttributes = CSharpStyle.base
}
}
}
enum CSharpStyle {
static let font = NSFont.monospacedSystemFont(ofSize: 15, weight: .regular)
static var base: [NSAttributedString.Key: Any] { [.font: font, .foregroundColor: NSColor.labelColor] }
private static func adaptive(_ light: Int, _ dark: Int) -> NSColor {
func color(_ hex: Int) -> NSColor {
NSColor(
srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: 1
)
}
return NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? color(dark) : color(light)
}
}
private static let keywords = [
"abstract", "as", "async", "await", "base", "bool", "break", "case", "catch", "char", "class",
"const", "continue", "decimal", "default", "do", "double", "else", "enum", "false", "finally",
"float", "for", "foreach", "get", "if", "in", "int", "interface", "internal", "is", "long",
"namespace", "new", "null", "object", "out", "override", "private", "protected", "public",
"readonly", "record", "ref", "return", "sealed", "set", "static", "string", "struct", "switch",
"this", "throw", "true", "try", "using", "var", "virtual", "void", "while",
]
private static let rules: [(NSRegularExpression, NSColor)] = {
func re(_ pattern: String) -> NSRegularExpression {
try! NSRegularExpression(pattern: pattern)
}
return [
(re("\\b(?:" + keywords.joined(separator: "|") + ")\\b"), adaptive(0x9B2393, 0xFF7AB2)),
(re("\\b(?:Console|Math|List|Dictionary|File|Task|Exception|Func|Action|String|DateTime|Random)\\b"), adaptive(0x0B4F79, 0x5DD8FF)),
(re("\\b\\d+(?:\\.\\d+)?\\b"), adaptive(0x1C00CF, 0xD9C97C)),
(re("\\$?@?\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])'"), adaptive(0xC41A16, 0xFC6A5D)),
(re("//[^\\n]*|/\\*[\\s\\S]*?\\*/"), adaptive(0x5D6C79, 0x7F8C98)),
]
}()
static func highlight(_ storage: NSTextStorage?) {
guard let storage else { return }
let text = storage.string
let full = NSRange(location: 0, length: (text as NSString).length)
storage.beginEditing()
storage.setAttributes(base, range: full)
for (pattern, color) in rules {
pattern.enumerateMatches(in: text, range: full) { match, _, _ in
if let range = match?.range {
storage.addAttribute(.foregroundColor, value: color, range: range)
}
}
}
storage.endEditing()
}
}
#endif
enum CSharpRunner {
static func run(_ source: String, stdin: String, done: @escaping (String, String) -> Void) {
#if os(macOS)
DispatchQueue.global(qos: .userInitiated).async {
let result = runMac(source, stdin: stdin)
DispatchQueue.main.async { done(result.0, result.1) }
}
#else
done("unavailable", "")
#endif
}
#if os(macOS)
private static func runMac(_ source: String, stdin: String) -> (String, String) {
guard source.count <= 100_000 else { return ("fail", "Program je příliš dlouhý.") }
guard let dotnet = findDotnet() else { return ("unavailable", "") }
let dir = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".graduately/csharp-lab")
let csproj = dir.appendingPathComponent("Lab.csproj")
if !FileManager.default.fileExists(atPath: csproj.path) {
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let framework = targetFramework(dotnet)
let xml = """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>\(framework)</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
"""
try? xml.write(to: csproj, atomically: true, encoding: .utf8)
let restored = command(dotnet, ["restore", dir.path], dir, "", 90)
if restored.0 != "ok" { return restored }
}
do {
try source.write(to: dir.appendingPathComponent("Program.cs"), atomically: true, encoding: .utf8)
} catch {
return ("fail", error.localizedDescription)
}
var args = ["run", "--project", dir.path, "--verbosity", "quiet"]
if FileManager.default.fileExists(atPath: dir.appendingPathComponent("obj").path) {
args.append("--no-restore")
}
return command(dotnet, args, dir, stdin, 20)
}
private static func command(_ dotnet: String, _ args: [String], _ dir: URL, _ stdin: String, _ seconds: Double) -> (String, String) {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: dotnet)
proc.arguments = args
proc.currentDirectoryURL = dir
var env = ProcessInfo.processInfo.environment
let root = (dotnet as NSString).deletingLastPathComponent
env["PATH"] = [root, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].joined(separator: ":")
env["DOTNET_ROOT"] = root
env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
env["DOTNET_NOLOGO"] = "1"
env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
proc.environment = env
let out = Pipe()
let input = Pipe()
proc.standardOutput = out
proc.standardError = out
proc.standardInput = input
var collected = Data()
let lock = NSLock()
out.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
guard !chunk.isEmpty else { return }
lock.lock()
collected.append(chunk)
lock.unlock()
}
do { try proc.run() } catch { return ("fail", error.localizedDescription) }
if let data = stdin.data(using: .utf8) { input.fileHandleForWriting.write(data) }
try? input.fileHandleForWriting.close()
let group = DispatchGroup()
group.enter()
proc.terminationHandler = { _ in group.leave() }
if group.wait(timeout: .now() + seconds) == .timedOut {
proc.terminate()
kill(proc.processIdentifier, SIGKILL)
out.fileHandleForReading.readabilityHandler = nil
return ("timeout", clip(collected, lock))
}
out.fileHandleForReading.readabilityHandler = nil
let rest = out.fileHandleForReading.readDataToEndOfFile()
lock.lock()
collected.append(rest)
lock.unlock()
let text = clip(collected, lock)
return proc.terminationStatus == 0 ? ("ok", text) : ("fail", text)
}
private static func clip(_ data: Data, _ lock: NSLock) -> String {
lock.lock()
let copy = data
lock.unlock()
let text = String(data: copy, encoding: .utf8) ?? ""
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.count <= 16_000 ? trimmed : String(trimmed.prefix(16_000)) + "\n…"
}
private static func targetFramework(_ dotnet: String) -> String {
let version = command(dotnet, ["--version"], URL(fileURLWithPath: NSTemporaryDirectory()), "", 20)
let major = version.1.split(separator: ".").first.map(String.init) ?? ""
return major.allSatisfy(\.isNumber) && !major.isEmpty ? "net\(major).0" : "net8.0"
}
private static func findDotnet() -> String? {
let names = [
"/usr/local/share/dotnet/dotnet",
"/opt/homebrew/share/dotnet/dotnet",
"/usr/local/bin/dotnet",
NSHomeDirectory() + "/.dotnet/dotnet",
]
return names.first { FileManager.default.isExecutableFile(atPath: $0) }
}
#endif
}