Add a SwiftUI iOS app that plays the same maturita content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-18 14:01:01 +02:00
commit 6c7336c7bb
33 changed files with 4498 additions and 10 deletions

View file

@ -0,0 +1,60 @@
import Foundation
func normalizeAnswer(_ input: String?) -> String {
var pre = ""
for ch in (input ?? "").lowercased() {
switch ch {
case "ä": pre += "ae"
case "ö": pre += "oe"
case "ü": pre += "ue"
case "ß": pre += "ss"
default: pre.append(ch)
}
}
let decomp = pre.decomposedStringWithCanonicalMapping
var out = ""
var prevSpace = false
for scalar in decomp.unicodeScalars {
let v = scalar.value
if (0x0300...0x036F).contains(v) || (0x1AB0...0x1AFF).contains(v) ||
(0x1DC0...0x1DFF).contains(v) || (0x20D0...0x20FF).contains(v) ||
(0xFE20...0xFE2F).contains(v) {
continue
}
if CharacterSet.whitespacesAndNewlines.contains(scalar) {
if !out.isEmpty && !prevSpace { out.append(" ") }
prevSpace = true
continue
}
if !CharacterSet.alphanumerics.contains(scalar) { continue }
prevSpace = false
out.unicodeScalars.append(scalar)
}
return out.trimmingCharacters(in: .whitespaces)
}
func answerAccepts(_ selNorm: String, _ ans: String?) -> Bool {
guard let ans, !ans.isEmpty else { return false }
return ans.split(separator: "|").contains { normalizeAnswer(String($0)) == selNorm }
}
func answerIncomplete(_ selNorm: String, _ ans: String?) -> Bool {
guard !selNorm.isEmpty, let ans, !ans.isEmpty else { return false }
for part in ans.split(separator: "|") {
let a = normalizeAnswer(String(part))
if a.hasPrefix(selNorm + " ") && a.count > selNorm.count + 1 { return true }
}
return false
}
func netTxtEq(_ a: String?, _ b: String?) -> Bool {
guard let a, let b else { return a == b }
let x = a.filter { !$0.isWhitespace }.lowercased()
let y = b.filter { !$0.isWhitespace }.lowercased()
return x == y
}
func hasUmlaut(_ s: String?) -> Bool {
guard let s else { return false }
return s.contains(where: { "äöüÄÖÜß".contains($0) })
}

View file

@ -0,0 +1,95 @@
import Foundation
enum AppConfig {
static var commit: String {
let raw = (Bundle.main.object(forInfoDictionaryKey: "GITCommit") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty || raw.hasPrefix("$(") ? "dev" : raw
}
static var versionName: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ios-1"
}
static var updateRepo: String {
(Bundle.main.object(forInfoDictionaryKey: "UPDATE_REPO") as? String)
?? "pepaskopec-blip/maturita.c"
}
static var updateBranch: String {
(Bundle.main.object(forInfoDictionaryKey: "UPDATE_BRANCH") as? String) ?? "builds"
}
}
final class Content {
let raw: J
let i18n: [String: (String?, String?)]
let subjects: [J]
let german: [J]
let net: J
let netLessons: [J]
let netTasks: [J]
let netAnswers: [[J]]
let hw: [J]
let mluvnice: [J]
let books: [J]
init(_ root: [String: Any]) {
raw = J(root)
let obj = root["i18n"] as? [String: Any] ?? [:]
var map: [String: (String?, String?)] = [:]
for (key, value) in obj {
let e = J(value)
let cs = e.strOrNull("cs")
let en = e.strOrNull("en")
map[key] = (cs, en)
}
i18n = map
subjects = raw.arr("subjects")
german = raw.arr("german")
net = raw.obj("net") ?? J([:])
netLessons = net.arr("lessons")
netTasks = net.arr("tasks")
if let a = net.o["answers"] as? [Any] {
netAnswers = a.map { block in
guard let rows = block as? [Any] else { return [] }
return rows.compactMap { item in
item is [String: Any] ? J(item) : nil
}
}
} else {
netAnswers = []
}
hw = raw.arr("hw")
mluvnice = raw.arr("mluvnice")
books = raw.arr("books")
}
func germanUnit(_ id: Int) -> J? { german.first { $0.int("id") == id } }
func netLesson(_ id: Int) -> J? { netLessons.first { $0.int("id") == id } }
func hwLesson(_ id: Int) -> J? { hw.first { $0.int("id") == id } }
func mluv(_ n: Int) -> J? { mluvnice.first { $0.int("id") == n } }
func book(_ id: String) -> J? { books.first { $0.str("id") == id } }
func tr(_ key: String?, lang: UiLang) -> String {
guard let key, !key.isEmpty else { return "" }
let hit = i18n[key]
if lang == .en { return hit?.1 ?? key }
return hit?.0 ?? key
}
func fmt(_ key: String, lang: UiLang, _ args: [CVarArg]) -> String {
let template = tr(key, lang: lang).replacingOccurrences(of: "%s", with: "%@")
return String(format: template, arguments: args)
}
static func load() -> Content {
guard let url = Bundle.main.url(forResource: "content", withExtension: "json"),
let data = try? Data(contentsOf: url),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
return Content([:])
}
return Content(obj)
}
}

View file

@ -0,0 +1,102 @@
import Foundation
struct J {
let o: [String: Any]
init(_ obj: Any?) {
o = obj as? [String: Any] ?? [:]
}
func has(_ key: String) -> Bool {
guard let value = o[key], !(value is NSNull) else { return false }
return true
}
func str(_ key: String, _ fallback: String = "") -> String {
guard has(key) else { return fallback }
if let s = o[key] as? String { return s }
if let n = o[key] as? NSNumber { return n.stringValue }
return fallback
}
func strOrNull(_ key: String) -> String? {
guard has(key) else { return nil }
if let s = o[key] as? String, !s.isEmpty, s != "null" { return s }
return nil
}
func bool(_ key: String, _ fallback: Bool = false) -> Bool {
if let b = o[key] as? Bool { return b }
if let n = o[key] as? NSNumber { return n.boolValue }
return fallback
}
func int(_ key: String, _ fallback: Int = 0) -> Int {
if let n = o[key] as? Int { return n }
if let n = o[key] as? NSNumber { return n.intValue }
if let s = o[key] as? String, let n = Int(s) { return n }
return fallback
}
func obj(_ key: String) -> J? {
guard let value = o[key], !(value is NSNull) else { return nil }
if value is [String: Any] { return J(value) }
return nil
}
func exercise(_ n: Int) -> J? {
guard let ex = o["exercises"] as? [String: Any] else { return nil }
return J(ex["\(n)"])
}
func arr(_ key: String) -> [J] {
guard let a = o[key] as? [Any] else { return [] }
return a.compactMap { item in
item is [String: Any] ? J(item) : nil
}
}
func strs(_ key: String) -> [String] {
guard let a = o[key] as? [Any] else { return [] }
return a.compactMap { item in
if item is NSNull { return nil }
if let s = item as? String, !s.isEmpty { return s }
return nil
}
}
func strsOrEmpty(_ key: String) -> [String?] {
guard let a = o[key] as? [Any] else { return [] }
return a.map { item in
if item is NSNull { return nil }
return item as? String
}
}
func intRows(_ key: String) -> [[String]] {
guard let a = o[key] as? [Any] else { return [] }
return a.map { row in
guard let cells = row as? [Any] else { return [] }
return cells.map { cell in
if let s = cell as? String { return s }
if let n = cell as? NSNumber { return n.stringValue }
return ""
}
}
}
func rawArray(_ key: String) -> [Any] {
o[key] as? [Any] ?? []
}
func answerList() -> [String] {
if let a = o["answers"] as? [Any] {
return a.compactMap { item in
if item is NSNull { return nil }
if let s = item as? String, !s.isEmpty { return s }
return nil
}
}
return [strOrNull("answers")].compactMap { $0 }
}
}

View file

@ -0,0 +1,81 @@
import Foundation
final class ProgressStore {
private let defaults = UserDefaults.standard
var themeId: ThemeId {
get { ThemeId(rawValue: defaults.integer(forKey: "theme")) ?? .catppuccin }
set { defaults.set(newValue.rawValue, forKey: "theme") }
}
var mode: ColorMode {
get { defaults.string(forKey: "mode") == "dark" ? .dark : .light }
set { defaults.set(newValue == .light ? "light" : "dark", forKey: "mode") }
}
var lang: UiLang {
get { defaults.string(forKey: "lang") == "en" ? .en : .cs }
set { defaults.set(newValue == .en ? "en" : "cs", forKey: "lang") }
}
func germanDone(_ unit: Int, _ ex: Int) -> Bool { defaults.bool(forKey: "g.\(unit).\(ex)") }
func markGerman(_ unit: Int, _ ex: Int) { defaults.set(true, forKey: "g.\(unit).\(ex)") }
func vocabDone(_ unit: Int) -> Bool { defaults.bool(forKey: "g.\(unit).vocab") }
func markVocab(_ unit: Int) { defaults.set(true, forKey: "g.\(unit).vocab") }
func netDone(_ id: Int) -> Bool { defaults.bool(forKey: "net.\(id)") }
func markNet(_ id: Int) { defaults.set(true, forKey: "net.\(id)") }
func hwDone(_ id: Int) -> Bool { defaults.bool(forKey: "hw.\(id)") }
func markHw(_ id: Int) { defaults.set(true, forKey: "hw.\(id)") }
func mluvDone(_ n: Int) -> Bool { defaults.bool(forKey: "mluv.\(n)") }
func markMluv(_ n: Int) { defaults.set(true, forKey: "mluv.\(n)") }
func bookQuiz(_ id: String) -> Bool { defaults.bool(forKey: "book.\(id).quiz") }
func markBookQuiz(_ id: String) { defaults.set(true, forKey: "book.\(id).quiz") }
func bookPlot(_ id: String) -> Bool { defaults.bool(forKey: "book.\(id).plot") }
func markBookPlot(_ id: String) { defaults.set(true, forKey: "book.\(id).plot") }
}
struct ProgressSum {
var doneEx = 0
var totalEx = 0
var doneUnits = 0
var openUnits = 0
}
func summarize(_ content: Content, _ p: ProgressStore) -> ProgressSum {
var sum = ProgressSum()
for u in content.german where u.bool("unlocked") {
let id = u.int("id")
let names = u.strs("names")
sum.totalEx += names.count
sum.openUnits += 1
let done = names.indices.filter { p.germanDone(id, $0 + 1) }.count
sum.doneEx += done
if !names.isEmpty && done == names.count { sum.doneUnits += 1 }
}
for l in content.netLessons {
sum.totalEx += 1
sum.openUnits += 1
if p.netDone(l.int("id")) {
sum.doneEx += 1
sum.doneUnits += 1
}
}
for l in content.hw {
sum.totalEx += 1
sum.openUnits += 1
if p.hwDone(l.int("id")) {
sum.doneEx += 1
sum.doneUnits += 1
}
}
sum.totalEx += content.mluvnice.count
sum.openUnits += 1
let md = content.mluvnice.filter { p.mluvDone($0.int("id")) }.count
sum.doneEx += md
if md == content.mluvnice.count && !content.mluvnice.isEmpty { sum.doneUnits += 1 }
return sum
}

View file

@ -0,0 +1,173 @@
import SwiftUI
enum ThemeId: Int, CaseIterable {
case catppuccin, nord, dracula, rosePine, ocean, gruvbox, solarized, everforest, monokai, oneDark
}
enum ColorMode {
case dark, light
}
enum UiLang {
case cs, en
}
let themeNames = [
"Catppuccin", "Nord", "Dracula", "Rose Pine", "Ocean",
"Gruvbox", "Solarized", "Everforest", "Monokai", "One Dark",
]
struct Palette {
let crust, base, mantle, surface0, surface1, surface2: Color
let overlay, text, subtext: Color
let accent, accent2, accent3: Color
let success, success2, warning, error: Color
let onAccent, node, lockedBg, lockedBorder, rail: Color
let finishDark, finishLight, finishStroke: Color
}
private func hx(_ v: UInt32) -> Color {
Color(
red: Double((v >> 16) & 0xFF) / 255,
green: Double((v >> 8) & 0xFF) / 255,
blue: Double(v & 0xFF) / 255
)
}
private func pal(_ c: [UInt32]) -> Palette {
Palette(
crust: hx(c[0]), base: hx(c[1]), mantle: hx(c[2]), surface0: hx(c[3]),
surface1: hx(c[4]), surface2: hx(c[5]), overlay: hx(c[6]), text: hx(c[7]),
subtext: hx(c[8]), accent: hx(c[9]), accent2: hx(c[10]), accent3: hx(c[11]),
success: hx(c[12]), success2: hx(c[13]), warning: hx(c[14]), error: hx(c[15]),
onAccent: hx(c[16]), node: hx(c[17]), lockedBg: hx(c[18]),
lockedBorder: hx(c[19]), rail: hx(c[20]), finishDark: hx(c[21]),
finishLight: hx(c[22]), finishStroke: hx(c[23])
)
}
func themePalette(_ id: ThemeId, _ mode: ColorMode) -> Palette {
let light = mode == .light
switch id {
case .catppuccin:
return light ? pal([
0xf3eee6, 0xfaf7f2, 0xffffff, 0xffffff, 0xf0ebe3, 0xe5dfd4,
0x8a8378, 0x1a1a1a, 0x5c574f, 0xffcc00, 0x0071e3, 0x2997ff,
0x1f8a4c, 0x1a7f4a, 0xe6a800, 0xd32f2f, 0x1a1a1a,
0xffffff, 0xf0ebe3, 0xd5cfc4, 0xe5dfd4, 0xf3eee6, 0xd5cfc4, 0x8a8378,
]) : pal([
0x000000, 0x000000, 0x111111, 0x1d1d1f, 0x2c2c2e, 0x3a3a3c,
0x86868b, 0xf5f5f7, 0xa1a1a6, 0xffd60a, 0x2997ff, 0x64d2ff,
0x30d158, 0x64d2ff, 0xffd60a, 0xff453a, 0x1d1d1f,
0x1d1d1f, 0x111111, 0x3a3a3c, 0x2c2c2e, 0x000000, 0x3a3a3c, 0x86868b,
])
case .nord:
return light ? pal([
0xd8dee9, 0xeceff4, 0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xb0b8c8,
0x7b88a1, 0x2e3440, 0x4c566a, 0x5e81ac, 0x81a1c1, 0x5e81ac,
0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0xeceff4,
0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xd8dee9, 0xd8dee9, 0xc7ceda, 0x7b88a1,
]) : pal([
0x2e3440, 0x3b4252, 0x2e3440, 0x434c5e, 0x4c566a, 0x616e88,
0x7b88a1, 0xeceff4, 0xd8dee9, 0x88c0d0, 0x81a1c1, 0x5e81ac,
0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0x2e3440,
0x434c5e, 0x3b4252, 0x4c566a, 0x2e3440, 0x2e3440, 0x434c5e, 0x4c566a,
])
case .dracula:
return light ? pal([
0xd5d7e0, 0xf8f8f2, 0xefefea, 0xe2e2d8, 0xd0d0c4, 0xbfbfb2,
0x9a9a8c, 0x282a36, 0x44475a, 0x7c3aed, 0xd946a6, 0x0891b2,
0x16a34a, 0x0e7490, 0xca8a04, 0xdc2626, 0xf8f8f2,
0xefefea, 0xe2e2d8, 0xd0d0c4, 0xe2e2d8, 0xd5d7e0, 0xd0d0c4, 0x9a9a8c,
]) : pal([
0x191a21, 0x282a36, 0x21222c, 0x44475a, 0x6272a4, 0x707eb0,
0x6272a4, 0xf8f8f2, 0xbfbfb2, 0xbd93f9, 0xff79c6, 0x8be9fd,
0x50fa7b, 0x8be9fd, 0xf1fa8c, 0xff5555, 0x282a36,
0x383a4a, 0x2f3240, 0x44475a, 0x21222c, 0x191a21, 0x383a4a, 0x6272a4,
])
case .rosePine:
return light ? pal([
0xf2e9e1, 0xfaf4ed, 0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xcecacd,
0x9893a5, 0x575279, 0x797593, 0x907aa9, 0xd7827e, 0x56949f,
0x286983, 0x56949f, 0xea9d34, 0xb4637a, 0xfaf4ed,
0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xf2e9e1, 0xf2e9e1, 0xdfdad9, 0x9893a5,
]) : pal([
0x191724, 0x1f1d2e, 0x1f1d2e, 0x26233a, 0x403d52, 0x524f67,
0x6e6a86, 0xe0def4, 0x908caa, 0xc4a7e7, 0xebbcba, 0x9ccfd8,
0x31748f, 0x9ccfd8, 0xf6c177, 0xeb6f92, 0x191724,
0x26233a, 0x21202e, 0x403d52, 0x191724, 0x191724, 0x26233a, 0x524f67,
])
case .ocean:
return light ? pal([
0xd6dae8, 0xe1e2ec, 0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0x9aa0b5,
0x7a8199, 0x2e3446, 0x4a5168, 0x2e7de9, 0x7847bd, 0x007197,
0x387068, 0x007197, 0x8c6c3e, 0xc64343, 0xe1e2ec,
0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0xc8c9d6, 0xd6dae8, 0xb4b6c5, 0x7a8199,
]) : pal([
0x16161e, 0x1a1b26, 0x16161e, 0x24283b, 0x414868, 0x565f89,
0x565f89, 0xc0caf5, 0xa9b1d6, 0x7aa2f7, 0xbb9af7, 0x7dcfff,
0x9ece6a, 0x73daca, 0xe0af68, 0xf7768e, 0x1a1b26,
0x24283b, 0x1f2335, 0x3b4261, 0x16161e, 0x16161e, 0x24283b, 0x414868,
])
case .gruvbox:
return light ? pal([
0xebdbb2, 0xfbf1c7, 0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xbdae93,
0x928374, 0x3c3836, 0x504945, 0xd65d0e, 0xb16286, 0x076678,
0x79740e, 0x427b58, 0xb57614, 0x9d0006, 0xfbf1c7,
0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xebdbb2, 0xebdbb2, 0xd5c4a1, 0x928374,
]) : pal([
0x1d2021, 0x282828, 0x1d2021, 0x3c3836, 0x504945, 0x665c54,
0x928374, 0xebdbb2, 0xa89984, 0xfe8019, 0xd3869b, 0x83a598,
0xb8bb26, 0x8ec07c, 0xfabd2f, 0xfb4934, 0x1d2021,
0x3c3836, 0x32302f, 0x504945, 0x1d2021, 0x1d2021, 0x3c3836, 0x504945,
])
case .solarized:
return light ? pal([
0xeee8d5, 0xfdf6e3, 0xeee8d5, 0xeee8d5, 0xe2dbc6, 0xc9c0a6,
0x657b83, 0x586e75, 0x657b83, 0x268bd2, 0x6c71c4, 0x2aa198,
0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0xfdf6e3,
0xeee8d5, 0xe6dfc8, 0x93a1a1, 0xeee8d5, 0xeee8d5, 0x93a1a1, 0x586e75,
]) : pal([
0x002b36, 0x073642, 0x002b36, 0x586e75, 0x657b83, 0x839496,
0x93a1a1, 0xfdf6e3, 0x93a1a1, 0x268bd2, 0x6c71c4, 0x2aa198,
0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0x002b36,
0x073642, 0x002b36, 0x586e75, 0x002b36, 0x002b36, 0x073642, 0x586e75,
])
case .everforest:
return light ? pal([
0xe6e2cc, 0xfdf6e3, 0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xa6b0a0,
0x939f91, 0x5c6a72, 0x829181, 0x8da101, 0xdf69ba, 0x35a77c,
0x8da101, 0x35a77c, 0xdfa000, 0xf85552, 0xfdf6e3,
0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xe6e2cc, 0xe6e2cc, 0xe0dcc7, 0x939f91,
]) : pal([
0x1e2326, 0x272e33, 0x2d353b, 0x374145, 0x4f5b58, 0x7a8478,
0x859289, 0xd3c6aa, 0xa7c080, 0xa7c080, 0xd699b6, 0x7fbbb3,
0xa7c080, 0x83c092, 0xdbbc7f, 0xe67e80, 0x272e33,
0x374145, 0x2d353b, 0x4f5b58, 0x1e2326, 0x1e2326, 0x374145, 0x4f5b58,
])
case .monokai:
return light ? pal([
0xe6e3d4, 0xfaf8f0, 0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xb8b49e,
0x8a8674, 0x2d2a2e, 0x5b5854, 0xe14775, 0x7c5cbf, 0x1c7d9b,
0x5b8c2a, 0x1c7d9b, 0xb08900, 0xe14775, 0xfaf8f0,
0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xe6e3d4, 0xe6e3d4, 0xd6d2c0, 0x8a8674,
]) : pal([
0x1d1e19, 0x272822, 0x1e1f1a, 0x3e3d32, 0x49483e, 0x75715e,
0x75715e, 0xf8f8f2, 0xa59f85, 0xf92672, 0xae81ff, 0x66d9ef,
0xa6e22e, 0xa1efe4, 0xe6db74, 0xf92672, 0x272822,
0x3e3d32, 0x2e2f29, 0x49483e, 0x1d1e19, 0x1d1e19, 0x3e3d32, 0x49483e,
])
case .oneDark:
return light ? pal([
0xe5e5e6, 0xfafafa, 0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7,
0xa0a1a7, 0x383a42, 0x696c77, 0xa626a4, 0x4078f2, 0x0184bc,
0x50a14f, 0x0184bc, 0xc18401, 0xe45649, 0xfafafa,
0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xe5e5e6, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7,
]) : pal([
0x21252b, 0x282c34, 0x21252b, 0x3b4048, 0x4b5263, 0x5c6370,
0x5c6370, 0xabb2bf, 0x7f848e, 0xc678dd, 0x61afef, 0x56b6c2,
0x98c379, 0x56b6c2, 0xe5c07b, 0xe06c75, 0x282c34,
0x3b4048, 0x2c313a, 0x4b5263, 0x21252b, 0x21252b, 0x3b4048, 0x4b5263,
])
}
}