diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 56f5d3d..e1d5293 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -194,9 +194,29 @@ jobs:
name: maturita-android
path: dist-android/maturita-android.apk
+ ios:
+ name: iOS IPA
+ runs-on: macos-14
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Extract content and build IPA
+ env:
+ COMMIT: ${{ github.sha }}
+ DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
+ run: |
+ chmod +x scripts/bundle-ios.sh
+ ./scripts/bundle-ios.sh
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: maturita-ios
+ path: dist-ios/maturita-ios.ipa
+
publish:
name: Publish to builds branch
- needs: [linux, windows, macos, android]
+ needs: [linux, windows, macos, android, ios]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/.gitignore b/.gitignore
index fd0137f..5437850 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,10 @@ android/*.iml
android/.cxx/
android/.local-sdk/
android/app/src/main/assets/content.json
+
+# iOS
+ios/DerivedData/
+ios/build/
+ios/Payload/
+ios/Maturita/content.json
+ios/**/xcuserdata/
diff --git a/Makefile b/Makefile
index b911362..7f6505f 100644
--- a/Makefile
+++ b/Makefile
@@ -119,4 +119,8 @@ android:
python3 scripts/extract-android-content.py
cd android && ./gradlew :app:assembleRelease
-.PHONY: all clean run bundle installer-zips android
+ios:
+ chmod +x scripts/bundle-ios.sh
+ ./scripts/bundle-ios.sh
+
+.PHONY: all clean run bundle installer-zips android ios
diff --git a/README.md b/README.md
index b9acd25..a45ce90 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ větve.
| Windows (x64) | [maturita-installer-windows.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-windows.zip) | Rozbalit a dvojklik; u SmartScreenu **Další informace → Přesto spustit** |
| Linux (x86_64) | [maturita-installer-linux.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-linux.zip) | Rozbalit a dvojklik na instalátor |
| Android (8+) | [maturita-installer-android.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-android.zip) | Rozbalit, otevřít **Stahnout maturita.C.html** a nainstalovat APK (neznámé zdroje) |
+| iOS (16+) | [maturita-installer-ios.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-ios.zip) | Rozbalit, otevřít **Jak nainstalovat maturita.C.html**. Nejjednodušší: Xcode na Macu, nebo IPA přes AltStore |
Kam se to nainstaluje: `/Applications` nebo `~/Applications` (macOS),
`%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux).
@@ -51,9 +52,10 @@ spolehlivější instalátor výše, nebo stažení přímo z té stránky větv
- [maturita-windows-x64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-windows-x64.zip)
- [maturita-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-linux-x86_64.AppImage)
- [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-android.apk)
+- [maturita-ios.ipa](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-ios.ipa)
Postup a nastavení se ukládají vedle `.app` / složky / AppImage do `progress/`.
-Na Androidu do úložiště aplikace (stejné statistiky, témata a jazyk).
+Na Androidu a iOS do úložiště aplikace (stejné statistiky, témata a jazyk).
Intel Mac, jiná architektura nebo úpravy kódu → [sestavení ze zdroje](#sestavení-ze-zdroje).
@@ -138,10 +140,11 @@ make bundle # dist/maturita.exe jde spustit i z Průzkumník
```
src/ C zdroje a maturita.h
android/ Jetpack Compose přehrávač (stejný obsah)
+ios/ SwiftUI přehrávač (stejný obsah)
data/ style.css, share/, podpisy, Windows .rc
assets/ zdrojová ikona a screenshoty
installers/ instalátory, které stáhnou aktuální build
-scripts/ balení AppImage, .app a extract-android-content.py
+scripts/ balení AppImage, .app, IPA a extract-android-content.py
.github/workflows/ CI: push na main → větev builds
progress/ vzniká za běhu (cvičení, nastavení)
```
@@ -157,6 +160,15 @@ cd android
nebo z kořene `make android`.
+iOS ze zdroje (Xcode 16, iOS 16+):
+
+```bash
+python3 scripts/extract-android-content.py
+open ios/Maturita.xcodeproj
+# Signing: vlastní Team, Run na iPhone
+# nebo: make ios # unsigned IPA v dist-ios/maturita-ios.ipa
+```
+
Licence: [GPL-3.0](LICENSE).
---
@@ -186,6 +198,7 @@ branch-named file.
| Windows (x64) | [maturita-installer-windows.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-windows.zip) | Unzip and double-click; if SmartScreen appears, **More info → Run anyway** |
| Linux (x86_64) | [maturita-installer-linux.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-linux.zip) | Unzip and double-click the installer |
| Android (8+) | [maturita-installer-android.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-android.zip) | Unzip, open **Stahnout maturita.C.html**, then install the APK (unknown sources) |
+| iOS (16+) | [maturita-installer-ios.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-ios.zip) | Unzip, open **Jak nainstalovat maturita.C.html**. Easiest: Xcode on a Mac, or the IPA via AltStore |
Install locations: `/Applications` or `~/Applications` (macOS),
`%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux).
@@ -206,9 +219,10 @@ above, or download from that branch page:
- [maturita-windows-x64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-windows-x64.zip)
- [maturita-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-linux-x86_64.AppImage)
- [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-android.apk)
+- [maturita-ios.ipa](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-ios.ipa)
Progress and settings live in `progress/` next to the `.app` / folder /
-AppImage. On Android they stay in app storage (same stats, themes, language).
+AppImage. On Android and iOS they stay in app storage (same stats, themes, language).
Intel Mac, another architecture, or hacking on the code →
[build from source](#build-from-source).
@@ -294,10 +308,11 @@ make bundle # dist/maturita.exe can be double-clicked
```
src/ C sources and maturita.h
android/ Jetpack Compose player (same content)
+ios/ SwiftUI player (same content)
data/ style.css, share/, signing files, Windows .rc
assets/ source icon and screenshots
installers/ fetch-the-latest installers
-scripts/ AppImage / .app bundlers and extract-android-content.py
+scripts/ AppImage / .app / IPA bundlers and extract-android-content.py
.github/workflows/ CI: push to main → builds branch
progress/ created at runtime (exercises, settings)
```
@@ -313,4 +328,13 @@ cd android
or `make android` from the repo root.
+iOS from source (Xcode 16, iOS 16+):
+
+```bash
+python3 scripts/extract-android-content.py
+open ios/Maturita.xcodeproj
+# Signing: your Team, Run on iPhone
+# or: make ios # unsigned IPA at dist-ios/maturita-ios.ipa
+```
+
License: [GPL-3.0](LICENSE).
diff --git a/installers/maturita-installer-ios.zip b/installers/maturita-installer-ios.zip
new file mode 100644
index 0000000..99dd15c
Binary files /dev/null and b/installers/maturita-installer-ios.zip differ
diff --git a/installers/maturita-ios.html b/installers/maturita-ios.html
new file mode 100644
index 0000000..fcc5eac
--- /dev/null
+++ b/installers/maturita-ios.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+ maturita.C pro iOS
+
+
+
+ maturita.C pro iPhone
+
+ Apple nepovolí instalaci jako Android APK. Nejjednodušší cesta je Mac
+ s Xcode a vlastní iPhone (bezplatný Apple účet stačí na 7 dní).
+
+
+ - Naklonuj maturita.c
+ - Otevři
ios/Maturita.xcodeproj
+ - V Signing vyber svůj Team
+ - Připoj iPhone a stiskni Run
+
+
+ IPA z větve builds jde nahrát i přes AltStore nebo Sideloadly
+ (unsigned balíček, potřebuješ nástroj na sideload).
+
+
+
+ Stáhnout IPA
+
+
+
+ Když odkaz hlásí 404, otevři
+ větev builds
+ a stáhni maturita-ios.ipa odtud.
+
+
+
+
diff --git a/installers/pack-installer-zips.sh b/installers/pack-installer-zips.sh
index fa54bed..8b870c4 100755
--- a/installers/pack-installer-zips.sh
+++ b/installers/pack-installer-zips.sh
@@ -32,3 +32,8 @@ cp maturita-android.html "$tmp/Stahnout maturita.C.html"
rm -f "$here/maturita-installer-android.zip"
(cd "$tmp" && zip -q -FS "$here/maturita-installer-android.zip" \
"Stahnout maturita.C.html")
+
+cp maturita-ios.html "$tmp/Jak nainstalovat maturita.C.html"
+rm -f "$here/maturita-installer-ios.zip"
+(cd "$tmp" && zip -q -FS "$here/maturita-installer-ios.zip" \
+ "Jak nainstalovat maturita.C.html")
diff --git a/ios/Maturita.xcodeproj/project.pbxproj b/ios/Maturita.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..85bfdc9
--- /dev/null
+++ b/ios/Maturita.xcodeproj/project.pbxproj
@@ -0,0 +1,370 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ A101000000000000000001 /* MaturitaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000001 /* MaturitaApp.swift */; };
+ A101000000000000000002 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000002 /* AppModel.swift */; };
+ A101000000000000000003 /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000003 /* Route.swift */; };
+ A101000000000000000004 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000004 /* JSON.swift */; };
+ A101000000000000000005 /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000005 /* Content.swift */; };
+ A101000000000000000006 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000006 /* Theme.swift */; };
+ A101000000000000000007 /* Answers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000007 /* Answers.swift */; };
+ A101000000000000000008 /* ProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000008 /* ProgressStore.swift */; };
+ A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; };
+ A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; };
+ A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.swift */; };
+ A101000000000000000012 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000012 /* PathMap.swift */; };
+ A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; };
+ A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; };
+ A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.swift */; };
+ A101000000000000000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A201000000000000000016 /* Assets.xcassets */; };
+ A101000000000000000017 /* content.json in Resources */ = {isa = PBXBuildFile; fileRef = A201000000000000000017 /* content.json */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ A201000000000000000001 /* MaturitaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaturitaApp.swift; sourceTree = ""; };
+ A201000000000000000002 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; };
+ A201000000000000000003 /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route.swift; sourceTree = ""; };
+ A201000000000000000004 /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; };
+ A201000000000000000005 /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; };
+ A201000000000000000006 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; };
+ A201000000000000000007 /* Answers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Answers.swift; sourceTree = ""; };
+ A201000000000000000008 /* ProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressStore.swift; sourceTree = ""; };
+ A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; };
+ A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; };
+ A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; };
+ A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = ""; };
+ A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = ""; };
+ A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = ""; };
+ A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = ""; };
+ A201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ A201000000000000000017 /* content.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = content.json; sourceTree = ""; };
+ A201000000000000000018 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A301000000000000000001 /* Maturita.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Maturita.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ A401000000000000000001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ A501000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ A501000000000000000002 /* Maturita */,
+ A501000000000000000006 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A501000000000000000002 /* Maturita */ = {
+ isa = PBXGroup;
+ children = (
+ A201000000000000000001 /* MaturitaApp.swift */,
+ A201000000000000000002 /* AppModel.swift */,
+ A201000000000000000003 /* Route.swift */,
+ A501000000000000000003 /* Data */,
+ A501000000000000000004 /* Update */,
+ A501000000000000000005 /* UI */,
+ A201000000000000000016 /* Assets.xcassets */,
+ A201000000000000000017 /* content.json */,
+ A201000000000000000018 /* Info.plist */,
+ );
+ path = Maturita;
+ sourceTree = "";
+ };
+ A501000000000000000003 /* Data */ = {
+ isa = PBXGroup;
+ children = (
+ A201000000000000000004 /* JSON.swift */,
+ A201000000000000000005 /* Content.swift */,
+ A201000000000000000006 /* Theme.swift */,
+ A201000000000000000007 /* Answers.swift */,
+ A201000000000000000008 /* ProgressStore.swift */,
+ );
+ path = Data;
+ sourceTree = "";
+ };
+ A501000000000000000004 /* Update */ = {
+ isa = PBXGroup;
+ children = (
+ A201000000000000000009 /* Updater.swift */,
+ );
+ path = Update;
+ sourceTree = "";
+ };
+ A501000000000000000005 /* UI */ = {
+ isa = PBXGroup;
+ children = (
+ A201000000000000000010 /* Components.swift */,
+ A201000000000000000011 /* Icons.swift */,
+ A201000000000000000012 /* PathMap.swift */,
+ A201000000000000000013 /* Screens.swift */,
+ A201000000000000000014 /* Exercises.swift */,
+ A201000000000000000015 /* ExercisesMore.swift */,
+ );
+ path = UI;
+ sourceTree = "";
+ };
+ A501000000000000000006 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ A301000000000000000001 /* Maturita.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ A601000000000000000001 /* Maturita */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A801000000000000000002 /* Build configuration list for PBXNativeTarget "Maturita" */;
+ buildPhases = (
+ A701000000000000000004 /* Extract content */,
+ A701000000000000000001 /* Sources */,
+ A401000000000000000001 /* Frameworks */,
+ A701000000000000000002 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Maturita;
+ productName = Maturita;
+ productReference = A301000000000000000001 /* Maturita.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ A001000000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1600;
+ LastUpgradeCheck = 1600;
+ TargetAttributes = {
+ A601000000000000000001 = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ };
+ };
+ buildConfigurationList = A801000000000000000001 /* Build configuration list for PBXProject "Maturita" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = cs;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ cs,
+ Base,
+ );
+ mainGroup = A501000000000000000001;
+ productRefGroup = A501000000000000000006 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ A601000000000000000001 /* Maturita */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ A701000000000000000002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A101000000000000000016 /* Assets.xcassets in Resources */,
+ A101000000000000000017 /* content.json in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ A701000000000000000004 /* Extract content */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Extract content";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(SRCROOT)/Maturita/content.json",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "python3 \"${SRCROOT}/../scripts/extract-android-content.py\"\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ A701000000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A101000000000000000001 /* MaturitaApp.swift in Sources */,
+ A101000000000000000002 /* AppModel.swift in Sources */,
+ A101000000000000000003 /* Route.swift in Sources */,
+ A101000000000000000004 /* JSON.swift in Sources */,
+ A101000000000000000005 /* Content.swift in Sources */,
+ A101000000000000000006 /* Theme.swift in Sources */,
+ A101000000000000000007 /* Answers.swift in Sources */,
+ A101000000000000000008 /* ProgressStore.swift in Sources */,
+ A101000000000000000009 /* Updater.swift in Sources */,
+ A101000000000000000010 /* Components.swift in Sources */,
+ A101000000000000000011 /* Icons.swift in Sources */,
+ A101000000000000000012 /* PathMap.swift in Sources */,
+ A101000000000000000013 /* Screens.swift in Sources */,
+ A101000000000000000014 /* Exercises.swift in Sources */,
+ A101000000000000000015 /* ExercisesMore.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ A901000000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_TESTABILITY = YES;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ A901000000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 5.0;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ A901000000000000000003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ COMMIT = dev;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Maturita/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "maturita.c";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = ios-1;
+ PRODUCT_BUNDLE_IDENTIFIER = org.maturita.maturita;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ A901000000000000000004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ COMMIT = dev;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Maturita/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "maturita.c";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = ios-1;
+ PRODUCT_BUNDLE_IDENTIFIER = org.maturita.maturita;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ A801000000000000000001 /* Build configuration list for PBXProject "Maturita" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A901000000000000000001 /* Debug */,
+ A901000000000000000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A801000000000000000002 /* Build configuration list for PBXNativeTarget "Maturita" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A901000000000000000003 /* Debug */,
+ A901000000000000000004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = A001000000000000000001 /* Project object */;
+}
diff --git a/ios/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Maturita.xcodeproj/xcshareddata/xcschemes/Maturita.xcscheme b/ios/Maturita.xcodeproj/xcshareddata/xcschemes/Maturita.xcscheme
new file mode 100644
index 0000000..0ea63f4
--- /dev/null
+++ b/ios/Maturita.xcodeproj/xcshareddata/xcschemes/Maturita.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Maturita/AppModel.swift b/ios/Maturita/AppModel.swift
new file mode 100644
index 0000000..46af406
--- /dev/null
+++ b/ios/Maturita/AppModel.swift
@@ -0,0 +1,113 @@
+import Foundation
+import SwiftUI
+
+final class AppModel: ObservableObject {
+ let content = Content.load()
+ let progress = ProgressStore()
+ let updater = Updater()
+
+ @Published var themeId: ThemeId
+ @Published var mode: ColorMode
+ @Published var lang: UiLang
+ @Published var settingsOpen = false
+ @Published var searchOpen = false
+ @Published var searchQuery = ""
+ @Published var tick = 0
+ @Published var update = UpdateState()
+ @Published var stack: [Route] = [.welcome]
+
+ init() {
+ themeId = progress.themeId
+ mode = progress.mode
+ lang = progress.lang
+ }
+
+ var route: Route { stack.last ?? .welcome }
+ var canGoBack: Bool { stack.count > 1 }
+ var palette: Palette { themePalette(themeId, mode) }
+
+ func tr(_ key: String?) -> String { content.tr(key, lang: lang) }
+
+ func fmt(_ key: String, _ args: CVarArg...) -> String {
+ content.fmt(key, lang: lang, args)
+ }
+
+ func go(_ r: Route) {
+ if stack.last == r { return }
+ stack.append(r)
+ searchOpen = false
+ }
+
+ func goPage(_ page: String?) {
+ guard let page, let r = routeFromPage(page) else { return }
+ go(r)
+ }
+
+ func back() {
+ if stack.count > 1 { stack.removeLast() }
+ }
+
+ func setTheme(_ id: ThemeId) {
+ themeId = id
+ progress.themeId = id
+ }
+
+ func applyMode(_ m: ColorMode) {
+ mode = m
+ progress.mode = m
+ }
+
+ func applyLang(_ l: UiLang) {
+ lang = l
+ progress.lang = l
+ }
+
+ func refresh() { tick += 1 }
+
+ func markGerman(_ unit: Int, _ ex: Int) {
+ progress.markGerman(unit, ex)
+ refresh()
+ }
+
+ func markVocab(_ unit: Int) {
+ progress.markVocab(unit)
+ refresh()
+ }
+
+ func markNet(_ id: Int) {
+ progress.markNet(id)
+ refresh()
+ }
+
+ func markHw(_ id: Int) {
+ progress.markHw(id)
+ refresh()
+ }
+
+ func markMluv(_ n: Int) {
+ progress.markMluv(n)
+ refresh()
+ }
+
+ func markBookQuiz(_ id: String) {
+ progress.markBookQuiz(id)
+ refresh()
+ }
+
+ func markBookPlot(_ id: String) {
+ progress.markBookPlot(id)
+ refresh()
+ }
+
+ func checkUpdate(_ interactive: Bool) {
+ updater.check(interactive: interactive) { [weak self] state in
+ self?.update = state
+ }
+ }
+
+ func installUpdate() {
+ updater.install { [weak self] state in
+ self?.update = state
+ }
+ }
+}
diff --git a/ios/Maturita/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Maturita/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/ios/Maturita/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Maturita/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/Maturita/Assets.xcassets/AppIcon.appiconset/AppIcon.png
new file mode 100644
index 0000000..80afb9d
Binary files /dev/null and b/ios/Maturita/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ
diff --git a/ios/Maturita/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Maturita/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..cefcc87
--- /dev/null
+++ b/ios/Maturita/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "AppIcon.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Maturita/Assets.xcassets/Contents.json b/ios/Maturita/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/Maturita/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Maturita/Data/Answers.swift b/ios/Maturita/Data/Answers.swift
new file mode 100644
index 0000000..b94dd7d
--- /dev/null
+++ b/ios/Maturita/Data/Answers.swift
@@ -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) })
+}
diff --git a/ios/Maturita/Data/Content.swift b/ios/Maturita/Data/Content.swift
new file mode 100644
index 0000000..e556d5b
--- /dev/null
+++ b/ios/Maturita/Data/Content.swift
@@ -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)
+ }
+}
diff --git a/ios/Maturita/Data/JSON.swift b/ios/Maturita/Data/JSON.swift
new file mode 100644
index 0000000..137e769
--- /dev/null
+++ b/ios/Maturita/Data/JSON.swift
@@ -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 }
+ }
+}
diff --git a/ios/Maturita/Data/ProgressStore.swift b/ios/Maturita/Data/ProgressStore.swift
new file mode 100644
index 0000000..b8c6432
--- /dev/null
+++ b/ios/Maturita/Data/ProgressStore.swift
@@ -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
+}
diff --git a/ios/Maturita/Data/Theme.swift b/ios/Maturita/Data/Theme.swift
new file mode 100644
index 0000000..2ff0a59
--- /dev/null
+++ b/ios/Maturita/Data/Theme.swift
@@ -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,
+ ])
+ }
+}
diff --git a/ios/Maturita/Info.plist b/ios/Maturita/Info.plist
new file mode 100644
index 0000000..3935383
--- /dev/null
+++ b/ios/Maturita/Info.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDisplayName
+ maturita.c
+ GITCommit
+ $(COMMIT)
+ UPDATE_REPO
+ pepaskopec-blip/maturita.c
+ UPDATE_BRANCH
+ builds
+ ITSAppUsesNonExemptEncryption
+
+ UILaunchScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/ios/Maturita/MaturitaApp.swift b/ios/Maturita/MaturitaApp.swift
new file mode 100644
index 0000000..c40c214
--- /dev/null
+++ b/ios/Maturita/MaturitaApp.swift
@@ -0,0 +1,154 @@
+import SwiftUI
+
+@main
+struct MaturitaApp: App {
+ @StateObject private var vm = AppModel()
+
+ var body: some Scene {
+ WindowGroup {
+ MaturitaRoot(vm: vm)
+ }
+ }
+}
+
+struct MaturitaRoot: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ let p = vm.palette
+ ZStack {
+ p.base.ignoresSafeArea()
+ VStack(spacing: 0) {
+ HStack {
+ Text("maturita.c")
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(p.text)
+ Spacer()
+ IconHit(action: { vm.searchOpen = true }) { SearchIcon(color: p.text) }
+ IconHit(action: { vm.go(.stats) }) { StatsIcon(color: p.text) }
+ IconHit(action: { vm.settingsOpen = true }) { SettingsIcon(color: p.text) }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ if vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged" {
+ HStack {
+ Text(bannerText)
+ .font(.system(size: 13))
+ .foregroundStyle(p.text)
+ Spacer()
+ if vm.update.canInstall {
+ PillButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() }
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(p.surface1, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .padding(.horizontal, 12)
+ }
+ routeView
+ .id(vm.tick)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ if vm.searchOpen {
+ SearchOverlay(vm: vm).ignoresSafeArea(edges: .bottom)
+ }
+ if vm.settingsOpen {
+ SettingsSheet(vm: vm).ignoresSafeArea(edges: .bottom)
+ }
+ }
+ .preferredColorScheme(vm.mode == .dark ? .dark : .light)
+ .onAppear { vm.checkUpdate(false) }
+ }
+
+ private var bannerText: String {
+ if let arg = vm.update.messageArg {
+ return vm.fmt(vm.update.messageKey, arg)
+ }
+ return vm.tr(vm.update.messageKey)
+ }
+
+ @ViewBuilder
+ private var routeView: some View {
+ switch vm.route {
+ case .welcome: WelcomeScreen(vm: vm)
+ case .subjects: SubjectsScreen(vm: vm)
+ case .stats: StatsScreen(vm: vm)
+ 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:
+ LessonMapScreen(
+ vm: vm,
+ title: vm.tr("net_year1"),
+ sub: vm.tr("net_sub"),
+ nodes: vm.content.netLessons.map { l in
+ let id = l.int("id")
+ return MapNode(
+ id: "n\(id)",
+ label: vm.tr(l.str("titleKey")),
+ locked: false,
+ done: vm.progress.netDone(id),
+ current: !vm.progress.netDone(id),
+ onClick: { vm.go(.netLesson(id)) }
+ )
+ }
+ )
+ case .netLesson(let id):
+ if let l = vm.content.netLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), sub: vm.tr(l.str("subKey")), slides: l.arr("slides")) {
+ vm.go(.netEx(id))
+ }
+ }
+ case .netEx(let id): NetQuizScreen(vm: vm, id: id)
+ case .hwMap:
+ LessonMapScreen(
+ vm: vm,
+ title: vm.tr("Technické vybavení"),
+ sub: vm.tr("hw_sub"),
+ nodes: vm.content.hw.map { l in
+ let id = l.int("id")
+ return MapNode(
+ id: "h\(id)",
+ label: vm.tr(l.str("titleKey")),
+ locked: false,
+ done: vm.progress.hwDone(id),
+ current: !vm.progress.hwDone(id),
+ onClick: { vm.go(.hwLesson(id)) }
+ )
+ }
+ )
+ case .hwLesson(let id):
+ if let l = vm.content.hwLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), sub: vm.tr(l.str("subKey")), slides: l.arr("slides")) {
+ vm.go(.hwEx(id))
+ }
+ }
+ case .hwEx(let id): HwQuizScreen(vm: vm, id: id)
+ case .czechMap: CzechMapScreen(vm: vm)
+ case .mluvnice:
+ LessonMapScreen(
+ vm: vm,
+ title: vm.tr("Mluvnice"),
+ sub: "Cvičení z mluvnice – styl maturita / přijímačky z ČJL",
+ nodes: vm.content.mluvnice.map { m in
+ let n = m.int("id")
+ return MapNode(
+ id: "m\(n)",
+ label: m.str("name"),
+ locked: false,
+ done: vm.progress.mluvDone(n),
+ current: !vm.progress.mluvDone(n),
+ onClick: { vm.go(.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)
+ }
+ }
+}
diff --git a/ios/Maturita/Route.swift b/ios/Maturita/Route.swift
new file mode 100644
index 0000000..5fd8a78
--- /dev/null
+++ b/ios/Maturita/Route.swift
@@ -0,0 +1,98 @@
+import Foundation
+
+enum Route: Equatable {
+ case welcome
+ case subjects
+ case stats
+ case roadmap
+ case unitMap(Int)
+ case germanEx(Int, Int)
+ case vocab(Int)
+ case netYears
+ case netMap
+ case netLesson(Int)
+ case netEx(Int)
+ case hwMap
+ case hwLesson(Int)
+ case hwEx(Int)
+ case czechMap
+ case mluvnice
+ case mluvEx(Int)
+ case readingList
+ case book(String)
+ case bookQuiz(String)
+ case bookPlot(String)
+
+ var page: String {
+ switch self {
+ case .welcome: return "welcome"
+ case .subjects: return "subjects"
+ case .stats: return "stats"
+ case .roadmap: return "roadmap"
+ case .unitMap(let unitId): return "unit\(unitId + 1)"
+ case .germanEx(let unitId, let ex): return "u\(unitId + 1)e\(ex)"
+ case .vocab(let unitId): return "u\(unitId + 1)vocab"
+ case .netYears: return "netyears"
+ case .netMap: return "netmap"
+ case .netLesson(let id): return "netunit\(id)"
+ case .netEx(let id): return "netex\(id)"
+ case .hwMap: return "hwmap"
+ case .hwLesson(let id): return "hwunit\(id)"
+ case .hwEx(let id): return "hwex\(id)"
+ case .czechMap: return "czechmap"
+ case .mluvnice: return "mluvnice"
+ case .mluvEx(let n): return "mluve\(n)"
+ case .readingList: return "readinglist"
+ case .book(let id): return id == "1984" ? "cetba1984" : "cetbaFuks"
+ case .bookQuiz(let id): return id == "1984" ? "cetba1984quiz" : "cetbaFuksQuiz"
+ case .bookPlot(let id): return id == "1984" ? "cetba1984dej" : "cetbaFuksDej"
+ }
+ }
+}
+
+func routeFromPage(_ page: String) -> Route? {
+ switch page {
+ case "welcome": return .welcome
+ case "subjects": return .subjects
+ case "stats": return .stats
+ case "roadmap": return .roadmap
+ case "netyears": return .netYears
+ case "netmap": return .netMap
+ case "hwmap": return .hwMap
+ case "czechmap": return .czechMap
+ case "mluvnice": return .mluvnice
+ case "readinglist": return .readingList
+ case "cetba1984": return .book("1984")
+ case "cetba1984quiz": return .bookQuiz("1984")
+ case "cetba1984dej": return .bookPlot("1984")
+ case "cetbaFuks": return .book("fuks")
+ case "cetbaFuksQuiz": return .bookQuiz("fuks")
+ case "cetbaFuksDej": return .bookPlot("fuks")
+ default:
+ if let m = page.wholeMatch(of: /unit(\d+)/), let n = Int(m.1) {
+ return .unitMap(n - 1)
+ }
+ if let m = page.wholeMatch(of: /u(\d+)e(\d+)/), let u = Int(m.1), let e = Int(m.2) {
+ return .germanEx(u - 1, e)
+ }
+ if let m = page.wholeMatch(of: /u(\d+)vocab/), let u = Int(m.1) {
+ return .vocab(u - 1)
+ }
+ if let m = page.wholeMatch(of: /netunit(\d+)/), let n = Int(m.1) {
+ return .netLesson(n)
+ }
+ if let m = page.wholeMatch(of: /netex(\d+)/), let n = Int(m.1) {
+ return .netEx(n)
+ }
+ if let m = page.wholeMatch(of: /hwunit(\d+)/), let n = Int(m.1) {
+ return .hwLesson(n)
+ }
+ if let m = page.wholeMatch(of: /hwex(\d+)/), let n = Int(m.1) {
+ return .hwEx(n)
+ }
+ if let m = page.wholeMatch(of: /mluve(\d+)/), let n = Int(m.1) {
+ return .mluvEx(n)
+ }
+ return nil
+ }
+}
diff --git a/ios/Maturita/UI/Components.swift b/ios/Maturita/UI/Components.swift
new file mode 100644
index 0000000..d020074
--- /dev/null
+++ b/ios/Maturita/UI/Components.swift
@@ -0,0 +1,276 @@
+import SwiftUI
+
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 6
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ arrange(width: proposal.width ?? .infinity, subviews: subviews).size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let result = arrange(width: bounds.width, subviews: subviews)
+ for (i, frame) in result.frames.enumerated() {
+ subviews[i].place(
+ at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY),
+ proposal: ProposedViewSize(frame.size)
+ )
+ }
+ }
+
+ private func arrange(width: CGFloat, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) {
+ var frames: [CGRect] = []
+ var x: CGFloat = 0
+ var y: CGFloat = 0
+ var rowH: CGFloat = 0
+ var maxW: CGFloat = 0
+ for view in subviews {
+ let size = view.sizeThatFits(.unspecified)
+ if x > 0 && x + size.width > width {
+ x = 0
+ y += rowH + spacing
+ rowH = 0
+ }
+ frames.append(CGRect(origin: CGPoint(x: x, y: y), size: size))
+ x += size.width + spacing
+ rowH = max(rowH, size.height)
+ maxW = max(maxW, x - spacing)
+ }
+ return (CGSize(width: max(maxW, 0), height: y + rowH), frames)
+ }
+}
+
+struct PrimaryButton: View {
+ let label: String
+ let palette: Palette
+ var action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(label)
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(palette.onAccent)
+ .padding(.horizontal, 22)
+ .padding(.vertical, 12)
+ .background(palette.accent, in: Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct PillButton: View {
+ let label: String
+ let palette: Palette
+ var action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(label)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(palette.text)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(palette.text.opacity(0.06), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct IconHit: View {
+ var action: () -> Void
+ @ViewBuilder var content: () -> Content
+
+ var body: some View {
+ Button(action: action) {
+ content()
+ .frame(width: 40, height: 40)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct PageTop: View {
+ @ObservedObject var vm: AppModel
+ var back: () -> Void
+ let title: String
+ var subtitle: String?
+
+ var body: some View {
+ let p = vm.palette
+ VStack(spacing: 6) {
+ ZStack {
+ Text(title)
+ .font(.system(size: 22, weight: .medium))
+ .foregroundStyle(p.text)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 48)
+ HStack {
+ IconHit(action: back) { BackIcon(color: p.text) }
+ Spacer()
+ }
+ }
+ if let subtitle, !subtitle.isEmpty {
+ Text(subtitle)
+ .font(.system(size: 14))
+ .foregroundStyle(p.subtext)
+ .multilineTextAlignment(.center)
+ }
+ }
+ .padding(.top, 4)
+ .padding(.bottom, 14)
+ }
+}
+
+struct CardBox: View {
+ let palette: Palette
+ @ViewBuilder var content: () -> Content
+
+ var body: some View {
+ content()
+ .padding(20)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(palette.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .stroke(palette.text.opacity(0.07), lineWidth: 1)
+ )
+ }
+}
+
+struct FeedbackLine: View {
+ let text: String
+ let kind: String
+ let palette: Palette
+
+ var body: some View {
+ if !text.isEmpty {
+ Text(text)
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
+ .padding(.top, 8)
+ }
+ }
+}
+
+struct Meaning: View {
+ let text: String
+ let palette: Palette
+ let visible: Bool
+
+ var body: some View {
+ if visible && !text.isEmpty {
+ Text(text)
+ .font(.system(size: 13))
+ .foregroundStyle(palette.subtext)
+ .padding(.bottom, 8)
+ }
+ }
+}
+
+struct WordField: View {
+ @Binding var value: String
+ let palette: Palette
+ var placeholder: String
+ var mark: Bool?
+
+ var body: some View {
+ let border: Color = {
+ if mark == true { return palette.success }
+ if mark == false { return palette.error }
+ return palette.text.opacity(0.12)
+ }()
+ ZStack(alignment: .leading) {
+ if value.isEmpty {
+ Text(placeholder).foregroundStyle(palette.overlay).font(.system(size: 15))
+ }
+ TextField("", text: $value)
+ .font(.system(size: 15))
+ .foregroundStyle(palette.text)
+ .tint(palette.accent)
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ .background(palette.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1))
+ }
+}
+
+struct SegChip: View {
+ let label: String
+ let selected: Bool
+ let palette: Palette
+ var action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(label)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(palette.text)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ .background(selected ? palette.mantle : Color.clear, in: Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct Hint: View {
+ let text: String
+ let palette: Palette
+ var body: some View {
+ Text(text).font(.system(size: 13)).foregroundStyle(palette.subtext).padding(.bottom, 8)
+ }
+}
+
+struct Prompt: View {
+ let text: String
+ let palette: Palette
+ var body: some View {
+ Text(text).font(.system(size: 16, weight: .medium)).foregroundStyle(palette.text)
+ }
+}
+
+struct WordChip: View {
+ let text: String
+ let palette: Palette
+ var action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(text)
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(palette.text)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(palette.mantle, in: Capsule())
+ .overlay(Capsule().stroke(palette.text.opacity(0.1), lineWidth: 1))
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct OptionChip: View {
+ let text: String
+ let selected: Bool
+ let mark: Bool?
+ let palette: Palette
+ var action: () -> Void
+
+ var body: some View {
+ let border: Color = {
+ if mark == true { return palette.success }
+ if mark == false { return palette.error }
+ return selected ? palette.accent : palette.text.opacity(0.1)
+ }()
+ Button(action: action) {
+ Text(text)
+ .font(.system(size: 15))
+ .foregroundStyle(palette.text)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12)
+ .background(selected ? palette.surface1 : palette.mantle, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1))
+ }
+ .buttonStyle(.plain)
+ .padding(.bottom, 6)
+ }
+}
diff --git a/ios/Maturita/UI/Exercises.swift b/ios/Maturita/UI/Exercises.swift
new file mode 100644
index 0000000..300102c
--- /dev/null
+++ b/ios/Maturita/UI/Exercises.swift
@@ -0,0 +1,608 @@
+import SwiftUI
+
+struct ExerciseScaffold: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let action: String
+ var onAction: () -> Void
+ let feedback: String
+ let kind: String
+ @ViewBuilder var content: () -> Content
+
+ var body: some View {
+ let p = vm.palette
+ VStack(alignment: .leading, spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) { content() }
+ }
+ FeedbackLine(text: feedback, kind: kind, palette: p)
+ PrimaryButton(label: action, palette: p, action: onAction)
+ .padding(.vertical, 12)
+ }
+ .padding(.horizontal, 16)
+ }
+}
+
+struct GermanExercise: View {
+ @ObservedObject var vm: AppModel
+ let unitId: Int
+ let ex: Int
+
+ var body: some View {
+ if let spec = vm.content.germanUnit(unitId)?.exercise(ex) {
+ DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) {
+ vm.markGerman(unitId, ex)
+ }
+ }
+ }
+}
+
+struct VocabExercise: View {
+ @ObservedObject var vm: AppModel
+ let unitId: Int
+
+ var body: some View {
+ if let spec = vm.content.germanUnit(unitId)?.obj("vocab") {
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr("Vokabeltraining")) {
+ vm.markVocab(unitId)
+ }
+ }
+ }
+}
+
+struct MluvExercise: View {
+ @ObservedObject var vm: AppModel
+ let n: Int
+
+ var body: some View {
+ if let spec = vm.content.mluv(n) {
+ DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) {
+ vm.markMluv(n)
+ }
+ }
+ }
+}
+
+struct DispatchExercise: View {
+ @ObservedObject var vm: AppModel
+ let spec: J
+ let titleFallback: String
+ var onDone: () -> Void
+
+ var body: some View {
+ let title = spec.str("title").isEmpty ? titleFallback : spec.str("title")
+ let sub = spec.strOrNull("sub").map { vm.tr($0) }
+ switch spec.str("type") {
+ case "choice": ChoiceEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "assembly": AssemblyEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "typed", "kw": TypedEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "free", "profile": FreeEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "assign": AssignEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "hangman": HangmanEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "vocab": VocabEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "dialog": DialogEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "number", "count", "seq", "verb", "verbclue", "verb_sections", "ordne", "fill":
+ ComboEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "ex2": Ex2Ex(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "letters", "letters_gap": LettersEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "table": TableEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ case "reveal": RevealEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
+ default: Text("?").foregroundStyle(vm.palette.text)
+ }
+ }
+}
+
+private struct ChoiceEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var picks: [Int]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _picks = State(initialValue: Array(repeating: -1, count: spec.arr("questions").count))
+ }
+
+ var body: some View {
+ let qs = spec.arr("questions")
+ let meanings = spec.strs("meanings")
+ let expls = spec.strs("expls")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ let ok = qs.indices.filter { picks[$0] == qs[$0].int("correct") }.count
+ if ok == qs.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry_short"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ ForEach(qs.indices, id: \.self) { i in
+ Text("\(i + 1). \(qs[i].str("prompt"))")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(p.text)
+ .padding(.bottom, 6)
+ ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in
+ let sel = picks[i] == o
+ let mark: Bool? = revealed && sel ? picks[i] == qs[i].int("correct") : nil
+ OptionChip(text: opt, selected: sel, mark: mark, palette: p) { picks[i] = o }
+ }
+ if revealed {
+ if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
+ if i < expls.count { Meaning(text: expls[i], palette: p, visible: true) }
+ }
+ Spacer().frame(height: 10)
+ }
+ }
+ }
+}
+
+private struct AssemblyEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var placed: [[Int]]
+ @State private var pools: [[Int]]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let items = spec.arr("items")
+ _placed = State(initialValue: items.map { _ in [] })
+ _pools = State(initialValue: items.map { item in
+ Array(0.. Void
+ @State private var fields: [[String]]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows")
+ _fields = State(initialValue: items.map { q in
+ Array(repeating: "", count: max(1, q.strs("hints").count, q.answerList().count))
+ })
+ }
+
+ var body: some View {
+ let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows")
+ let p = vm.palette
+ let umlaut = items.contains { q in
+ q.answerList().contains { hasUmlaut($0) } || hasUmlaut(q.str("prompt"))
+ }
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ var ok = 0, total = 0, incomplete = false
+ for (i, q) in items.enumerated() {
+ let answers = q.answerList()
+ for (j, value) in fields[i].enumerated() {
+ total += 1
+ let ans = j < answers.count ? answers[j] : ""
+ let norm = normalizeAnswer(value)
+ if answerAccepts(norm, ans) { ok += 1 }
+ else if answerIncomplete(norm, ans) { incomplete = true }
+ }
+ }
+ if total > 0 && ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else if incomplete { fb = (vm.tr("trans_incomplete"), "warn") }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ if let note = spec.strOrNull("note") { Hint(text: note, palette: p) }
+ if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: p) }
+ if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)", palette: p) }
+ if umlaut { Hint(text: vm.tr("hint_umlauts"), palette: p) }
+ ForEach(items.indices, id: \.self) { i in
+ let q = items[i]
+ Prompt(text: q.str("prompt"), palette: p)
+ let answers = q.answerList()
+ let hints = q.strs("hints")
+ ForEach(fields[i].indices, id: \.self) { j in
+ let ans = j < answers.count ? answers[j] : ""
+ WordField(
+ value: Binding(get: { fields[i][j] }, set: { fields[i][j] = $0 }),
+ palette: p,
+ placeholder: j < hints.count ? hints[j] : vm.tr("your_answer"),
+ mark: revealed ? answerAccepts(normalizeAnswer(fields[i][j]), ans) : nil
+ )
+ .padding(.bottom, 4)
+ }
+ if revealed {
+ if let m = q.strOrNull("meaning") { Meaning(text: vm.tr(m), palette: p, visible: true) }
+ if let m = q.strOrNull("mean") { Meaning(text: vm.tr(m), palette: p, visible: true) }
+ if let g = q.strOrNull("german") { Meaning(text: g, palette: p, visible: true) }
+ if let s = q.strOrNull("shown") { Meaning(text: s, palette: p, visible: true) }
+ }
+ Spacer().frame(height: 10)
+ }
+ }
+ }
+}
+
+private struct FreeEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var shown: [Bool]
+ @State private var values: [String]
+ @State private var fb = ("", "")
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions")
+ _shown = State(initialValue: Array(repeating: false, count: qs.count))
+ _values = State(initialValue: Array(repeating: "", count: qs.count))
+ }
+
+ var body: some View {
+ let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: {
+ fb = (vm.tr("feedback_ok"), "ok")
+ shown = shown.map { _ in true }
+ onDone()
+ }, feedback: fb.0, kind: fb.1) {
+ if let tip = spec.strOrNull("tip") { Hint(text: vm.tr(tip), palette: p) }
+ ForEach(qs.indices, id: \.self) { i in
+ let q = qs[i]
+ Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question"), palette: p)
+ WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), palette: p, placeholder: vm.tr("your_answer"))
+ PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() }
+ .padding(.vertical, 6)
+ if shown[i] {
+ let sample = q.str("sample")
+ let qcs = q.str("qCs")
+ let acs = q.str("aCs")
+ Meaning(
+ text: qcs.isEmpty ? sample : vm.fmt("sample_fmt", vm.tr(qcs), sample, vm.tr(acs)),
+ palette: p,
+ visible: true
+ )
+ }
+ Spacer().frame(height: 10)
+ }
+ }
+ }
+}
+
+private struct AssignEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var active = 0
+ @State private var loc: [Int]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _loc = State(initialValue: Array(repeating: -1, count: spec.arr("items").count))
+ }
+
+ var body: some View {
+ let items = spec.arr("items")
+ let groups = spec.strs("groups")
+ let meanings = spec.strs("meanings")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ let ok = items.indices.allSatisfy { loc[$0] == items[$0].int("group") }
+ if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ Hint(text: vm.tr("assign_hint"), palette: p)
+ FlowLayout(spacing: 8) {
+ ForEach(groups.indices, id: \.self) { i in
+ SegChip(label: groups[i], selected: active == i, palette: p) { active = i }
+ }
+ }
+ Text(vm.tr("wordbank")).font(.system(size: 12)).foregroundStyle(p.subtext).padding(.top, 10)
+ FlowLayout(spacing: 6) {
+ ForEach(items.indices, id: \.self) { i in
+ if loc[i] == -1 {
+ let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
+ WordChip(text: label, palette: p) { loc[i] = active }
+ }
+ }
+ }
+ ForEach(groups.indices, id: \.self) { g in
+ Text(groups[g]).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 10)
+ FlowLayout(spacing: 6) {
+ ForEach(items.indices, id: \.self) { i in
+ if loc[i] == g {
+ let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
+ WordChip(text: label, palette: p) { loc[i] = -1 }
+ }
+ }
+ }
+ }
+ if revealed {
+ ForEach(items.indices, id: \.self) { i in
+ if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
+ }
+ }
+ }
+ }
+}
+
+private struct HangmanEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var word = 0
+ @State private var misses = 0
+ @State private var guessed: [Bool]
+ @State private var fb = ("", "")
+ @State private var finished = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _guessed = State(initialValue: Array(repeating: false, count: spec.strs("letters").count))
+ }
+
+ var body: some View {
+ let words = spec.strs("words")
+ let tips = spec.strs("tips")
+ let letters = spec.strs("letters")
+ let p = vm.palette
+ let cur = word < words.count ? words[word].uppercased() : ""
+ VStack(alignment: .leading, spacing: 8) {
+ PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
+ Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(p.subtext)
+ if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))", palette: p) }
+ Canvas { ctx, size in
+ var gallows = Path()
+ gallows.move(to: CGPoint(x: 40, y: size.height - 10))
+ gallows.addLine(to: CGPoint(x: size.width * 0.55, y: size.height - 10))
+ gallows.move(to: CGPoint(x: 70, y: size.height - 10))
+ gallows.addLine(to: CGPoint(x: 70, y: 16))
+ gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, y: 16))
+ ctx.stroke(gallows, with: .color(p.text), style: StrokeStyle(lineWidth: 6, lineCap: .round))
+ let x = size.width * 0.3 + 40
+ if misses > 0 {
+ var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36))
+ ctx.stroke(rope, with: .color(p.text), lineWidth: 4)
+ }
+ if misses > 1 {
+ ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(p.text), lineWidth: 4)
+ }
+ if misses > 2 {
+ var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110))
+ ctx.stroke(body, with: .color(p.text), lineWidth: 4)
+ }
+ if misses > 3 {
+ var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100))
+ ctx.stroke(a, with: .color(p.text), lineWidth: 4)
+ }
+ if misses > 4 {
+ var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100))
+ ctx.stroke(a, with: .color(p.text), lineWidth: 4)
+ }
+ if misses > 5 {
+ var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140))
+ ctx.stroke(a, with: .color(p.text), lineWidth: 4)
+ }
+ }
+ .frame(height: 160)
+ HStack(spacing: 6) {
+ ForEach(Array(cur.enumerated()), id: \.offset) { _, ch in
+ let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
+ let show = idx.map { guessed[$0] } ?? false
+ Text(show ? String(ch) : "_")
+ .font(.system(size: 26, weight: .bold))
+ .foregroundStyle(p.text)
+ }
+ }
+ ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in
+ HStack(spacing: 4) {
+ ForEach(row, id: \.self) { letter in
+ if let i = letters.firstIndex(of: letter) {
+ Button {
+ guessed[i] = true
+ if !cur.localizedCaseInsensitiveContains(letter) {
+ misses += 1
+ if misses >= 6 { fb = (vm.fmt("hm_fail", cur), "err") }
+ } else if cur.allSatisfy({ ch in
+ let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
+ return idx.map { guessed[$0] } ?? false
+ }) {
+ if word + 1 >= words.count {
+ finished = true
+ fb = (vm.tr("hm_done"), "ok")
+ onDone()
+ } else {
+ fb = (vm.tr("hm_wrong"), "ok")
+ word += 1
+ misses = 0
+ guessed = guessed.map { _ in false }
+ }
+ }
+ } label: {
+ Text(letter)
+ .font(.system(size: 15, weight: .bold))
+ .foregroundStyle(p.text)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .background(guessed[i] ? p.surface1 : p.mantle, in: RoundedRectangle(cornerRadius: 8))
+ }
+ .buttonStyle(.plain)
+ .disabled(guessed[i] || finished)
+ }
+ }
+ }
+ }
+ FeedbackLine(text: fb.0, kind: fb.1, palette: p)
+ if misses >= 6 && !finished {
+ PillButton(label: vm.tr("hm_retry"), palette: p) {
+ misses = 0
+ guessed = guessed.map { _ in false }
+ fb = ("", "")
+ }
+ }
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ }
+}
+
+private extension Array {
+ func chunked(_ size: Int) -> [[Element]] {
+ stride(from: 0, to: count, by: size).map { Array(self[$0.. Void
+ @State private var cards: [(String, String, String)]
+ @State private var index = 0
+ @State private var done = 0
+ @State private var value = ""
+ @State private var fb = ("", "")
+ let total: Int
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let list = spec.arr("sections").flatMap { sec in
+ sec.arr("rows").map { (sec.str("header"), $0.str("prompt"), $0.str("answers")) }
+ }
+ _cards = State(initialValue: list)
+ total = list.count
+ }
+
+ var body: some View {
+ let p = vm.palette
+ VStack(alignment: .leading, spacing: 8) {
+ PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
+ Text(vm.fmt("trans_progress", done, total)).foregroundStyle(p.subtext)
+ if cards.isEmpty {
+ Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success)
+ } else if index < cards.count {
+ let cur = cards[index]
+ Hint(text: cur.0, palette: p)
+ Prompt(text: cur.1, palette: p)
+ if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts"), palette: p) }
+ WordField(value: $value, palette: p, placeholder: vm.tr("your_answer"))
+ PrimaryButton(label: vm.tr("check"), palette: p) {
+ let norm = normalizeAnswer(value)
+ if answerAccepts(norm, cur.2) {
+ done += 1
+ cards.remove(at: index)
+ if cards.isEmpty { fb = (vm.tr("trans_done"), "ok"); onDone() }
+ else { if index >= cards.count { index = 0 }; value = ""; fb = ("", "") }
+ } else if answerIncomplete(norm, cur.2) {
+ fb = (vm.tr("trans_incomplete"), "warn")
+ } else {
+ fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err")
+ let item = cards.remove(at: index)
+ cards.append(item)
+ if index >= cards.count { index = 0 }
+ value = ""
+ }
+ }
+ FeedbackLine(text: fb.0, kind: fb.1, palette: p)
+ }
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ }
+}
diff --git a/ios/Maturita/UI/ExercisesMore.swift b/ios/Maturita/UI/ExercisesMore.swift
new file mode 100644
index 0000000..9e49d74
--- /dev/null
+++ b/ios/Maturita/UI/ExercisesMore.swift
@@ -0,0 +1,857 @@
+import SwiftUI
+
+struct DialogEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+
+ var body: some View {
+ let pool = spec.strs("pool")
+ let meanings = spec.strs("meanings")
+ let lines = dialogLines()
+ ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: lines.count, onDone: onDone) { picks, expected, revealed in
+ ForEach(lines.indices, id: \.self) { i in
+ let line = lines[i]
+ if let name = line.name {
+ Text(name)
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(vm.palette.subtext)
+ .padding(.vertical, 6)
+ }
+ ComboLine(
+ vm: vm, pool: pool, picks: picks, expected: expected, idx: i,
+ answer: line.answer,
+ prefix: line.prefix,
+ suffix: line.suffix,
+ meaning: i < meanings.count ? meanings[i] : nil,
+ revealed: revealed
+ )
+ }
+ }
+ }
+
+ private func dialogLines() -> [(name: String?, prefix: String, answer: String, suffix: String)] {
+ var out: [(name: String?, prefix: String, answer: String, suffix: String)] = []
+ for d in spec.arr("dialogues") {
+ var header: String? = d.str("name")
+ for row in d.arr("rows") {
+ out.append((
+ header,
+ [row.str("speaker"), row.str("before")].filter { !$0.isEmpty }.joined(separator: " "),
+ row.str("answer"),
+ row.str("after")
+ ))
+ header = nil
+ }
+ }
+ return out
+ }
+}
+
+struct ComboEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+
+ var body: some View {
+ let pool = spec.strs("pool")
+ let rows = spec.arr("rows")
+ let meanings = spec.strs("meanings")
+ let type = spec.str("type")
+ if type == "verb_sections" {
+ let sections = spec.arr("sections")
+ let allRows = sections.flatMap { $0.arr("rows") }
+ ComboRows(vm: vm, title: title, sub: sub, pool: spec.strs("pool"), n: allRows.count, onDone: onDone, revealMean: spec.bool("transUpfront")) { picks, expected, revealed in
+ ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in
+ if let title = line.title {
+ Text(title).font(.system(size: 15, weight: .semibold)).foregroundStyle(vm.palette.subtext)
+ }
+ ComboLine(
+ vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx,
+ answer: line.answer, prefix: line.before, suffix: line.after,
+ meaning: line.meaning,
+ revealed: revealed || spec.bool("transUpfront")
+ )
+ }
+ }
+ } else if type == "ordne" {
+ ComboRows(vm: vm, title: title, sub: sub, pool: [], n: rows.count * 2, onDone: onDone) { picks, expected, revealed in
+ ForEach(rows.indices, id: \.self) { i in
+ ComboLine(vm: vm, pool: spec.strs("fwPool"), picks: picks, expected: expected, idx: i * 2, answer: rows[i].str("fw"), prefix: "\(i + 1). ", suffix: rows[i].str("mid"), revealed: revealed)
+ ComboLine(vm: vm, pool: spec.strs("ansPool"), picks: picks, expected: expected, idx: i * 2 + 1, answer: rows[i].str("ans"), prefix: "→ ", meaning: i < meanings.count ? meanings[i] : nil, revealed: revealed)
+ }
+ }
+ } else if type == "fill" {
+ let blanks = rows.reduce(0) { $0 + $1.answerList().count }
+ ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
+ if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) }
+ ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
+ FlowLayout(spacing: 4) {
+ if let num = block.num {
+ Text(num).font(.system(size: 16, weight: .semibold)).foregroundStyle(vm.palette.text)
+ }
+ ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
+ switch part {
+ case .text(let s):
+ Text(s).foregroundStyle(vm.palette.text)
+ case .blank(let idx, let ans):
+ ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
+ }
+ }
+ if let mean = block.meaning {
+ Text(vm.tr(mean)).font(.system(size: 12)).foregroundStyle(vm.palette.subtext)
+ }
+ }
+ .padding(.bottom, 6)
+ if revealed || spec.bool("preMeaning") || spec.bool("showGerman"), let mean = block.rowMean {
+ Meaning(text: vm.tr(mean), palette: vm.palette, visible: true)
+ }
+ }
+ }
+ } else {
+ ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: rows.count, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
+ if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) }
+ ForEach(rows.indices, id: \.self) { i in
+ let r = rows[i]
+ let prefix: String = {
+ switch type {
+ case "number": return "\(r.str("digits")) = "
+ case "count": return "\(Array(repeating: r.str("emoji"), count: r.int("count")).joined(separator: " ")) \(r.str("noun")) "
+ case "seq": return r.str("before")
+ case "verbclue": return "\(r.str("emoji")) \(r.str("before"))"
+ default: return r.str("before")
+ }
+ }()
+ let suffix: String = {
+ switch type {
+ case "seq", "verbclue", "verb": return r.str("after")
+ default: return ""
+ }
+ }()
+ ComboLine(
+ vm: vm, pool: pool, picks: picks, expected: expected, idx: i, answer: r.str("answer"),
+ prefix: prefix, suffix: suffix,
+ meaning: i < meanings.count ? meanings[i] : r.strOrNull("mean"),
+ revealed: revealed || spec.bool("preMeaning")
+ )
+ }
+ }
+ }
+ }
+
+ private func verbSectionLines() -> [(title: String?, before: String, answer: String, after: String, meaning: String?)] {
+ var out: [(title: String?, before: String, answer: String, after: String, meaning: String?)] = []
+ for sec in spec.arr("sections") {
+ var header: String? = sec.str("title")
+ let meanings = sec.strs("meanings")
+ for (ri, row) in sec.arr("rows").enumerated() {
+ out.append((
+ header,
+ row.str("before"),
+ row.str("answer"),
+ row.str("after"),
+ ri < meanings.count ? meanings[ri] : nil
+ ))
+ header = nil
+ }
+ }
+ return out
+ }
+
+ private enum FillPart {
+ case text(String)
+ case blank(Int, String)
+ }
+
+ private func fillRows() -> [(num: String?, parts: [FillPart], meaning: String?, rowMean: String?)] {
+ let meanings = spec.strs("meanings")
+ var i = 0
+ return spec.arr("rows").enumerated().map { ri, r in
+ var parts: [FillPart] = []
+ let segs = r.strsOrEmpty("segs")
+ let ans = r.answerList()
+ for si in segs.indices {
+ if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
+ if si < ans.count {
+ parts.append(.blank(i, ans[si]))
+ i += 1
+ }
+ }
+ return (
+ r.strOrNull("num"),
+ parts,
+ ri < meanings.count ? meanings[ri] : nil,
+ r.strOrNull("mean")
+ )
+ }
+ }
+}
+
+private struct ComboRows: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let pool: [String]
+ let n: Int
+ var onDone: () -> Void
+ var revealMean = false
+ @ViewBuilder var bodyContent: (Binding<[String]>, Binding<[String]>, Bool) -> Body
+ @State private var picks: [String]
+ @State private var expected: [String]
+ @State private var fb = ("", "")
+ @State private var revealed: Bool
+
+ init(
+ vm: AppModel,
+ title: String,
+ sub: String?,
+ pool: [String],
+ n: Int,
+ onDone: @escaping () -> Void,
+ revealMean: Bool = false,
+ @ViewBuilder bodyContent: @escaping (Binding<[String]>, Binding<[String]>, Bool) -> Body
+ ) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.pool = pool
+ self.n = n
+ self.onDone = onDone
+ self.revealMean = revealMean
+ self.bodyContent = bodyContent
+ _picks = State(initialValue: Array(repeating: "", count: n))
+ _expected = State(initialValue: Array(repeating: "", count: n))
+ _revealed = State(initialValue: revealMean)
+ }
+
+ var body: some View {
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ let ok = expected.indices.allSatisfy { i in
+ let exp = expected[i]
+ return exp.isEmpty || answerAccepts(normalizeAnswer(picks[i]), exp)
+ }
+ if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ bodyContent($picks, $expected, revealed)
+ }
+ }
+}
+
+private struct ComboLine: View {
+ @ObservedObject var vm: AppModel
+ let pool: [String]
+ @Binding var picks: [String]
+ @Binding var expected: [String]
+ let idx: Int
+ let answer: String
+ var prefix: String = ""
+ var suffix: String = ""
+ var meaning: String?
+ var revealed: Bool
+ var inline = false
+ @State private var open = false
+
+ var body: some View {
+ let p = vm.palette
+ let pick = idx < picks.count ? picks[idx] : ""
+ let mark: Bool? = {
+ if revealed && !pick.isEmpty { return answerAccepts(normalizeAnswer(pick), answer) }
+ return nil
+ }()
+ let chip = Button {
+ open = true
+ } label: {
+ Text(pick.isEmpty ? "—" : pick)
+ .font(.system(size: 15))
+ .foregroundStyle(pick.isEmpty ? p.overlay : p.text)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(p.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(mark == true ? p.success : mark == false ? p.error : p.text.opacity(0.12), lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+
+ Group {
+ if inline {
+ chip
+ } else {
+ FlowLayout(spacing: 6) {
+ if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) }
+ chip
+ if !suffix.isEmpty { Text(suffix).font(.system(size: 16)).foregroundStyle(p.text) }
+ }
+ .padding(.bottom, 8)
+ }
+ }
+ .onAppear {
+ if idx >= 0 && idx < expected.count { expected[idx] = answer }
+ }
+ .onChange(of: answer) { new in
+ if idx >= 0 && idx < expected.count { expected[idx] = new }
+ }
+ .sheet(isPresented: $open) {
+ WordPicker(palette: p, pool: pool) { word in
+ if let word, idx < picks.count { picks[idx] = word }
+ open = false
+ }
+ .presentationDetents([.medium, .large])
+ }
+ if revealed, let meaning, !meaning.isEmpty {
+ Meaning(text: vm.tr(meaning), palette: p, visible: true)
+ }
+ }
+}
+
+struct WordPicker: View {
+ let palette: Palette
+ let pool: [String]
+ var onPick: (String?) -> Void
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(pool, id: \.self) { w in
+ Button { onPick(w) } label: {
+ Text(w)
+ .font(.system(size: 16))
+ .foregroundStyle(palette.text)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(10)
+ }
+ .background(palette.base)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(role: .cancel) { onPick(nil) } label: { Text("Zavřít") }
+ }
+ }
+ }
+ }
+}
+
+struct Ex2Ex: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+
+ var body: some View {
+ let items = spec.arr("items")
+ let pool = spec.strs("pool")
+ let blanks = items.reduce(0) { acc, item in
+ acc + item.arr("rows").reduce(0) { $0 + $1.strs("answers").count }
+ }
+ ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone) { picks, expected, revealed in
+ ForEach(Array(ex2Blocks().enumerated()), id: \.offset) { _, block in
+ Meaning(text: block.czech, palette: vm.palette, visible: true)
+ ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in
+ FlowLayout(spacing: 4) {
+ if let num = row.num {
+ Text(num).foregroundStyle(vm.palette.text).padding(.trailing, 6)
+ }
+ ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
+ switch part {
+ case .text(let s):
+ Text(s).foregroundStyle(vm.palette.text)
+ case .blank(let idx, let ans):
+ ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private enum Part {
+ case text(String)
+ case blank(Int, String)
+ }
+
+ private func ex2Blocks() -> [(czech: String, rows: [(num: String?, parts: [Part])])] {
+ var i = 0
+ return spec.arr("items").map { item in
+ let rows = item.arr("rows").map { row -> (num: String?, parts: [Part]) in
+ var parts: [Part] = []
+ let segs = row.strsOrEmpty("segs")
+ let ans = row.strs("answers")
+ for si in segs.indices {
+ if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
+ if si < ans.count {
+ parts.append(.blank(i, ans[si]))
+ i += 1
+ }
+ }
+ return (row.strOrNull("num"), parts)
+ }
+ return (item.str("czech"), rows)
+ }
+ }
+}
+
+struct LettersEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var fields: [String]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ let n = spec.arr("rows").flatMap { $0.strs("answers") }.count
+ _fields = State(initialValue: Array(repeating: "", count: n))
+ }
+
+ var body: some View {
+ let rows = spec.arr("rows")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ var i = 0, ok = 0, total = 0
+ for r in rows {
+ for ans in r.strs("answers") {
+ total += 1
+ if i < fields.count && answerAccepts(normalizeAnswer(fields[i]), ans) { ok += 1 }
+ i += 1
+ }
+ }
+ if ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ if rows.contains(where: { $0.strs("answers").contains { hasUmlaut($0) } }) {
+ Hint(text: vm.tr("hint_umlauts"), palette: p)
+ }
+ LettersRows(vm: vm, rows: rows, fields: $fields, revealed: revealed)
+ }
+ }
+}
+
+private struct LettersRows: View {
+ @ObservedObject var vm: AppModel
+ let rows: [J]
+ @Binding var fields: [String]
+ let revealed: Bool
+
+ var body: some View {
+ let p = vm.palette
+ let pairs = build()
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(pairs.indices, id: \.self) { ri in
+ let row = pairs[ri]
+ if let num = row.num { Text(num).foregroundStyle(p.subtext) }
+ FlowLayout {
+ ForEach(row.parts.indices, id: \.self) { pi in
+ switch row.parts[pi] {
+ case .text(let s):
+ Text(s).foregroundStyle(p.text)
+ case .field(let idx, let ans):
+ WordField(
+ value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
+ palette: p,
+ placeholder: "",
+ mark: revealed ? answerAccepts(normalizeAnswer(idx < fields.count ? fields[idx] : ""), ans) : nil
+ )
+ .frame(width: 72)
+ }
+ }
+ }
+ if revealed, let czech = row.czech { Meaning(text: czech, palette: p, visible: true) }
+ if revealed, let mean = row.mean { Meaning(text: mean, palette: p, visible: true) }
+ }
+ }
+ }
+
+ private enum Part {
+ case text(String)
+ case field(Int, String)
+ }
+
+ private struct RowModel {
+ var num: String?
+ var parts: [Part]
+ var czech: String?
+ var mean: String?
+ }
+
+ private func build() -> [RowModel] {
+ var i = 0
+ return rows.map { r in
+ var parts: [Part] = []
+ let segs = r.strsOrEmpty("segs")
+ let ans = r.strs("answers")
+ for si in segs.indices {
+ if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
+ if si < ans.count {
+ parts.append(.field(i, ans[si]))
+ i += 1
+ }
+ }
+ return RowModel(num: r.strOrNull("num"), parts: parts, czech: r.strOrNull("czech"), mean: r.strOrNull("mean"))
+ }
+ }
+}
+
+struct TableEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var fields: [String]
+ @State private var fb = ("", "")
+ @State private var revealed = false
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _fields = State(initialValue: Array(repeating: "", count: spec.strs("nouns").count * spec.strs("persons").count))
+ }
+
+ var body: some View {
+ let persons = spec.strs("persons")
+ let nouns = spec.strs("nouns")
+ let answers = spec.intRows("answers")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
+ var ok = 0
+ for (r, row) in answers.enumerated() {
+ for (c, ans) in row.enumerated() {
+ let i = r * persons.count + c
+ if i < fields.count && normalizeAnswer(fields[i]) == normalizeAnswer(ans) { ok += 1 }
+ }
+ }
+ if ok == fields.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
+ else { fb = (vm.tr("feedback_retry"), "err") }
+ revealed = true
+ }, feedback: fb.0, kind: fb.1) {
+ ForEach(nouns.indices, id: \.self) { r in
+ Text(nouns[r]).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
+ ForEach(persons.indices, id: \.self) { c in
+ let i = r * persons.count + c
+ Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext)
+ WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), palette: p, placeholder: "")
+ }
+ Spacer().frame(height: 8)
+ }
+ }
+ }
+}
+
+struct RevealEx: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ var sub: String?
+ let spec: J
+ var onDone: () -> Void
+ @State private var shown: [Bool]
+ @State private var fb = ("", "")
+
+ init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
+ self.vm = vm
+ self.title = title
+ self.sub = sub
+ self.spec = spec
+ self.onDone = onDone
+ _shown = State(initialValue: Array(repeating: false, count: spec.arr("items").count))
+ }
+
+ var body: some View {
+ let items = spec.arr("items")
+ let p = vm.palette
+ ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: {
+ shown = shown.map { _ in true }
+ fb = (vm.tr("feedback_ok"), "ok")
+ onDone()
+ }, feedback: fb.0, kind: fb.1) {
+ if let note = spec.strOrNull("note") { Hint(text: note, palette: p) }
+ ForEach(items.indices, id: \.self) { i in
+ Prompt(text: items[i].str("prompt"), palette: p)
+ PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() }
+ .padding(.vertical, 6)
+ if shown[i] { Meaning(text: items[i].str("solution"), palette: p, visible: true) }
+ Spacer().frame(height: 8)
+ }
+ }
+ }
+}
+
+struct NetQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: Int
+
+ var body: some View {
+ if let lesson = vm.content.netLesson(id) {
+ if id == 1 {
+ VlsmEx(vm: vm, lesson: lesson)
+ } else {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": "net_quiz_intro",
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ vm.markNet(id)
+ }
+ }
+ }
+ }
+}
+
+struct HwQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: Int
+
+ var body: some View {
+ if let lesson = vm.content.hwLesson(id) {
+ let spec = J([
+ "type": "choice",
+ "title": vm.tr(lesson.str("exTitleKey")),
+ "sub": lesson.str("quizHeadKey"),
+ "questions": lesson.rawArray("quiz"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
+ vm.markHw(id)
+ }
+ }
+ }
+}
+
+private struct VlsmRow {
+ var pfx = ""
+ var net = ""
+ var bc = ""
+ var lo = ""
+ var hi = ""
+}
+
+private struct VlsmEx: View {
+ @ObservedObject var vm: AppModel
+ let lesson: J
+ @State private var done: [Bool]
+ @State private var rows: [[VlsmRow]]
+ @State private var fb = ("", "")
+
+ init(vm: AppModel, lesson: J) {
+ self.vm = vm
+ self.lesson = lesson
+ let answers = vm.content.netAnswers
+ _done = State(initialValue: Array(repeating: false, count: vm.content.netTasks.count))
+ _rows = State(initialValue: answers.map { block in Array(repeating: VlsmRow(), count: block.count) })
+ }
+
+ var body: some View {
+ let tasks = vm.content.netTasks
+ let answers = vm.content.netAnswers
+ let p = vm.palette
+ VStack(alignment: .leading, spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_ex_title"), subtitle: nil)
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(tasks.indices, id: \.self) { ti in
+ let task = tasks[ti]
+ let block = ti < answers.count ? answers[ti] : []
+ let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 }
+ Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8)
+ if infeasible {
+ Hint(text: vm.tr("net_infeasible"), palette: p)
+ } else {
+ ForEach(block.indices, id: \.self) { si in
+ Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(p.subtext)
+ PrefixMenu(value: Binding(
+ get: { rows[ti][si].pfx },
+ set: { rows[ti][si].pfx = $0 }
+ ), palette: p)
+ WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), palette: p, placeholder: "síť")
+ WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), palette: p, placeholder: "broadcast")
+ WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), palette: p, placeholder: "první uzel")
+ WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), palette: p, placeholder: "poslední uzel")
+ }
+ PillButton(label: vm.tr("check"), palette: p) {
+ let ok = block.indices.allSatisfy { si in
+ let a = block[si]
+ let field = rows[ti][si]
+ return Int(field.pfx.replacingOccurrences(of: "/", with: "")) == a.int("pfx")
+ && netTxtEq(field.net, a.str("net"))
+ && netTxtEq(field.bc, a.str("bcast"))
+ && netTxtEq(field.lo, a.str("lo"))
+ && netTxtEq(field.hi, a.str("hi"))
+ }
+ if ok {
+ done[ti] = true
+ fb = (vm.tr("feedback_ok"), "ok")
+ maybeFinish()
+ } else {
+ fb = (vm.tr("feedback_retry"), "err")
+ }
+ }
+ .padding(.vertical, 6)
+ }
+ PillButton(label: vm.tr("net_solution"), palette: p) {
+ if !infeasible {
+ for si in block.indices {
+ rows[ti][si].pfx = "/\(block[si].int("pfx"))"
+ rows[ti][si].net = block[si].str("net")
+ rows[ti][si].bc = block[si].str("bcast")
+ rows[ti][si].lo = block[si].str("lo")
+ rows[ti][si].hi = block[si].str("hi")
+ }
+ }
+ done[ti] = true
+ fb = ("", "")
+ maybeFinish()
+ }
+ if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) }
+ }
+ }
+ }
+ FeedbackLine(text: fb.0, kind: fb.1, palette: p)
+ }
+ .padding(.horizontal, 16)
+ }
+
+ private func maybeFinish() {
+ if !done.isEmpty && done.allSatisfy({ $0 }) { vm.markNet(1) }
+ }
+}
+
+private struct PrefixMenu: View {
+ @Binding var value: String
+ let palette: Palette
+ @State private var open = false
+
+ var body: some View {
+ Button { open = true } label: {
+ Text(value.isEmpty ? "/…" : value)
+ .font(.system(size: 15))
+ .foregroundStyle(palette.text)
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(palette.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ .padding(.vertical, 4)
+ .sheet(isPresented: $open) {
+ WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
+ if let word { value = word }
+ open = false
+ }
+ .presentationDetents([.medium, .large])
+ }
+ }
+}
+
+struct LitQuizScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: String
+ @State private var idx = 0
+ @State private var score = 0
+ @State private var answered = false
+ @State private var finished = false
+
+ var body: some View {
+ if let book = vm.content.book(id) {
+ let qs = book.arr("quiz")
+ let p = vm.palette
+ let kahoot = [p.accent, p.error, p.warning, p.success]
+ VStack(alignment: .leading, spacing: 8) {
+ PageTop(vm: vm, back: { vm.back() }, title: vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
+ if finished {
+ Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
+ Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext)
+ Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
+ PrimaryButton(label: vm.tr("lit_again"), palette: p) {
+ idx = 0; score = 0; answered = false; finished = false
+ }
+ } else if idx < qs.count {
+ let q = qs[idx]
+ HStack {
+ Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)).foregroundStyle(p.subtext)
+ Spacer()
+ Text(vm.fmt("lit_score_fmt", score, qs.count)).foregroundStyle(p.subtext)
+ }
+ Text(q.str("prompt")).font(.system(size: 18, weight: .semibold)).foregroundStyle(p.text).padding(.vertical, 12)
+ ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
+ Button {
+ answered = true
+ if i == q.int("correct") { score += 1 }
+ } label: {
+ Text(opt)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(p.onAccent)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(16)
+ .background(kahoot[i % 4], in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ .disabled(answered)
+ .padding(.bottom, 8)
+ }
+ if answered {
+ Meaning(text: q.str("expl"), palette: p, visible: true)
+ PrimaryButton(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), palette: p) {
+ if idx + 1 >= qs.count {
+ finished = true
+ vm.markBookQuiz(id)
+ } else {
+ idx += 1
+ answered = false
+ }
+ }
+ }
+ }
+ Spacer()
+ }
+ .padding(16)
+ }
+ }
+}
+
+struct PlotScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: String
+
+ var body: some View {
+ if let book = vm.content.book(id) {
+ let spec = J([
+ "type": "assembly",
+ "title": vm.tr(book.str("plotTitle")),
+ "sub": book.str("plotSub"),
+ "items": book.rawArray("plot"),
+ "meanings": book.rawArray("plotMeaning"),
+ ])
+ DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(book.str("plotTitle"))) {
+ vm.markBookPlot(id)
+ }
+ }
+ }
+}
+
diff --git a/ios/Maturita/UI/Icons.swift b/ios/Maturita/UI/Icons.swift
new file mode 100644
index 0000000..9874349
--- /dev/null
+++ b/ios/Maturita/UI/Icons.swift
@@ -0,0 +1,304 @@
+import SwiftUI
+
+struct BackIcon: View {
+ var color: Color
+ var size: CGFloat = 16
+ var body: some View {
+ Canvas { ctx, sz in
+ var p = Path()
+ p.move(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.18))
+ p.addLine(to: CGPoint(x: sz.width * 0.28, y: sz.height * 0.5))
+ p.addLine(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.82))
+ ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct SearchIcon: View {
+ var color: Color
+ var size: CGFloat = 18
+ var body: some View {
+ Canvas { ctx, sz in
+ let r = min(sz.width, sz.height) * 0.28
+ let c = CGPoint(x: sz.width * 0.42, y: sz.height * 0.42)
+ ctx.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.12)
+ var handle = Path()
+ handle.move(to: CGPoint(x: sz.width * 0.62, y: sz.height * 0.62))
+ handle.addLine(to: CGPoint(x: sz.width * 0.82, y: sz.height * 0.82))
+ ctx.stroke(handle, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.12, lineCap: .round))
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct StatsIcon: View {
+ var color: Color
+ var size: CGFloat = 18
+ var body: some View {
+ Canvas { ctx, sz in
+ let w = min(sz.width, sz.height) * 0.16
+ let bars: [(CGFloat, CGFloat, CGFloat)] = [
+ (0.18, 0.48, 0.36), (0.42, 0.22, 0.62), (0.66, 0.36, 0.48),
+ ]
+ for b in bars {
+ let rect = CGRect(x: sz.width * b.0, y: sz.height * b.1, width: w, height: sz.height * b.2)
+ ctx.fill(Path(roundedRect: rect, cornerRadius: w), with: .color(color))
+ }
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct SettingsIcon: View {
+ var color: Color
+ var size: CGFloat = 18
+ var body: some View {
+ Canvas { ctx, sz in
+ let cx = sz.width / 2
+ let cy = sz.height / 2
+ let s = min(sz.width, sz.height)
+ let rOuter = s * 0.48
+ let rInner = s * 0.34
+ let rHole = s * 0.16
+ let teeth = 8
+ let toothHalf = CGFloat.pi / CGFloat(teeth)
+ var gear = Path()
+ for i in 0.. some View {
+ switch name {
+ case "globe": GlobeIcon(color: color)
+ case "people": PeopleIcon(color: color)
+ case "bulb": BulbIcon(color: color)
+ case "quiz": QuizIcon(color: color)
+ case "order": OrderIcon(color: color)
+ default: BookIcon(color: color)
+ }
+}
diff --git a/ios/Maturita/UI/PathMap.swift b/ios/Maturita/UI/PathMap.swift
new file mode 100644
index 0000000..d7796d1
--- /dev/null
+++ b/ios/Maturita/UI/PathMap.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+struct MapNode {
+ let id: String
+ let label: String
+ let locked: Bool
+ let done: Bool
+ let current: Bool
+ var finish: Bool = false
+ var onClick: (() -> Void)?
+}
+
+struct PathMap: View {
+ let nodes: [MapNode]
+ let palette: Palette
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(spacing: 10) {
+ ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in
+ Button {
+ if !node.locked { node.onClick?() }
+ } label: {
+ CardBox(palette: palette) {
+ HStack(spacing: 12) {
+ let bg: Color = {
+ if node.locked { return palette.lockedBg }
+ if node.current || node.done { return palette.accent }
+ return palette.node
+ }()
+ let fg: Color = {
+ if node.locked { return palette.overlay }
+ if node.current || node.done { return palette.onAccent }
+ return palette.text
+ }()
+ ZStack {
+ Circle().fill(bg).frame(width: 44, height: 44)
+ if node.locked {
+ LockIcon(color: palette.overlay, size: 16)
+ } else if node.done {
+ CheckIcon(color: palette.onAccent, size: 16)
+ } else {
+ Text("\(i + 1)")
+ .font(.system(size: 16, weight: .heavy))
+ .foregroundStyle(fg)
+ }
+ }
+ Text(node.label)
+ .font(.system(size: 16, weight: node.current ? .bold : .semibold))
+ .foregroundStyle(node.locked ? palette.overlay : palette.text)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(node.locked || node.onClick == nil)
+ .opacity(node.locked ? 0.48 : 1)
+ }
+ }
+ .padding(.bottom, 24)
+ }
+ }
+}
diff --git a/ios/Maturita/UI/Screens.swift b/ios/Maturita/UI/Screens.swift
new file mode 100644
index 0000000..419a36b
--- /dev/null
+++ b/ios/Maturita/UI/Screens.swift
@@ -0,0 +1,698 @@
+import SwiftUI
+
+struct WelcomeScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ let p = vm.palette
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ Text("maturita.c")
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(p.text)
+ Text("sestavení \(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
+ .font(.system(size: 12))
+ .foregroundStyle(p.overlay)
+ .padding(.bottom, 12)
+ HStack(alignment: .center, spacing: 12) {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(vm.tr("welcome_title"))
+ .font(.system(size: 32, weight: .medium))
+ .foregroundStyle(p.text)
+ Text(vm.tr("welcome_body_ios"))
+ .font(.system(size: 16))
+ .foregroundStyle(p.subtext)
+ PrimaryButton(label: vm.tr("continue"), palette: p) { vm.go(.subjects) }
+ }
+ Spacer(minLength: 0)
+ VStack {
+ Text(vm.tr("welcome_stat_value"))
+ .font(.system(size: 28, weight: .bold))
+ .foregroundStyle(p.text)
+ Text(vm.tr("welcome_stat_label"))
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(p.subtext)
+ .multilineTextAlignment(.center)
+ }
+ .frame(width: 108, height: 108)
+ .background(p.mantle, in: Circle())
+ .overlay(Circle().stroke(p.text.opacity(0.08), lineWidth: 1))
+ }
+ FeatureCard(vm: vm, t: "welcome_feat1_title", b: "welcome_feat1_body", l: "welcome_feat1_link")
+ .padding(.top, 28)
+ FeatureCard(vm: vm, t: "welcome_feat2_title", b: "welcome_feat2_body", l: "welcome_feat2_link")
+ .padding(.top, 12)
+ }
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+ }
+ }
+}
+
+private struct FeatureCard: View {
+ @ObservedObject var vm: AppModel
+ let t, b, l: String
+
+ var body: some View {
+ let p = vm.palette
+ CardBox(palette: p) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(vm.tr(t)).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text)
+ Text(vm.tr(b)).font(.system(size: 15)).foregroundStyle(p.subtext)
+ PillButton(label: vm.tr(l), palette: p) { vm.go(.subjects) }
+ .padding(.top, 4)
+ }
+ }
+ }
+}
+
+struct SubjectsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ let p = vm.palette
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: vm.tr("subjects_title"), subtitle: vm.tr("subjects_sub"))
+ .padding(.horizontal, 16)
+ ScrollView {
+ VStack(spacing: 12) {
+ ForEach(Array(stride(from: 0, to: vm.content.subjects.count, by: 2)), id: \.self) { i in
+ HStack(spacing: 12) {
+ subjectTile(vm.content.subjects[i], p)
+ if i + 1 < vm.content.subjects.count {
+ subjectTile(vm.content.subjects[i + 1], p)
+ } else {
+ Color.clear.frame(maxWidth: .infinity).frame(height: 132)
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 24)
+ }
+ }
+ }
+
+ private func subjectTile(_ s: J, _ p: Palette) -> some View {
+ let open = s.bool("open")
+ return Button {
+ if open { vm.goPage(s.str("target")) }
+ } label: {
+ VStack(spacing: 10) {
+ switch s.str("icon") {
+ case "de": GermanFlag(size: 44)
+ case "wifi": WifiIcon(color: p.text, size: 36)
+ case "chip": ChipIcon(color: p.text, size: 36)
+ case "cz": CzechFlag(size: 44)
+ default: LockIcon(color: p.overlay, size: 22)
+ }
+ Text(vm.tr(s.str("key")))
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(p.text)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 132)
+ .background(p.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .stroke(p.text.opacity(open ? 0.08 : 0.04), lineWidth: 1)
+ )
+ .opacity(open ? 1 : 0.48)
+ }
+ .buttonStyle(.plain)
+ .disabled(!open)
+ }
+}
+
+struct SettingsSheet: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ let p = vm.palette
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text(vm.tr("settings_title"))
+ .font(.system(size: 22, weight: .bold))
+ .foregroundStyle(p.text)
+ Spacer()
+ PillButton(label: vm.tr("back"), palette: p) { vm.settingsOpen = false }
+ }
+ Text(vm.tr("mode")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
+ HStack(spacing: 4) {
+ SegChip(label: vm.tr("mode_dark"), selected: vm.mode == .dark, palette: p) { vm.applyMode(.dark) }
+ SegChip(label: vm.tr("mode_light"), selected: vm.mode == .light, palette: p) { vm.applyMode(.light) }
+ }
+ .padding(3)
+ .background(p.surface1, in: Capsule())
+ Text(vm.tr("theme")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
+ ForEach(Array(themeNames.enumerated()), id: \.offset) { i, name in
+ if let id = ThemeId(rawValue: i) {
+ let sw = themePalette(id, vm.mode)
+ let sel = vm.themeId == id
+ Button { vm.setTheme(id) } label: {
+ HStack(spacing: 10) {
+ ZStack {
+ Circle().fill(sw.base)
+ Circle().fill(sw.accent).frame(width: 10, height: 10).offset(x: -8)
+ Circle().fill(sw.accent3).frame(width: 10, height: 10)
+ Circle().fill(sw.success).frame(width: 10, height: 10).offset(x: 8)
+ if sel { Circle().stroke(p.accent, lineWidth: 2.2) }
+ }
+ .frame(width: 36, height: 36)
+ Text(name).font(.system(size: 16, weight: .medium)).foregroundStyle(p.text)
+ Spacer()
+ }
+ .padding(10)
+ .background(sel ? p.surface1 : p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ Text(vm.tr("language")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
+ HStack(spacing: 4) {
+ SegChip(label: "Čeština", selected: vm.lang == .cs, palette: p) { vm.applyLang(.cs) }
+ SegChip(label: "English", selected: vm.lang == .en, palette: p) { vm.applyLang(.en) }
+ }
+ .padding(3)
+ .background(p.surface1, in: Capsule())
+ Text(vm.tr("updates")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
+ Text(updateText)
+ .font(.system(size: 14))
+ .foregroundStyle(p.subtext)
+ HStack(spacing: 8) {
+ PillButton(label: vm.tr("update_check"), palette: p) { vm.checkUpdate(true) }
+ if vm.update.canInstall {
+ PrimaryButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() }
+ }
+ }
+ .padding(.bottom, 24)
+ }
+ .padding(20)
+ }
+ .background(p.base)
+ }
+
+ 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 {
+ let p = vm.palette
+ let sum = summarize(vm.content, vm.progress)
+ let pct = sum.totalEx == 0 ? 0 : (100 * sum.doneEx / sum.totalEx)
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: vm.tr("stats_title"), subtitle: vm.tr("stats_sub"))
+ .padding(.horizontal, 16)
+ ScrollView {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 10) {
+ metric(p, vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
+ metric(p, vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", pct))
+ metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
+ }
+ ProgressBar(fraction: CGFloat(pct) / 100, palette: p, height: 8)
+ Text(vm.tr("stats_section"))
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(p.subtext)
+ .padding(.top, 10)
+ ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
+ let open = s.bool("open")
+ let part = subjectSum(vm, s)
+ CardBox(palette: p) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(vm.tr(s.str("key"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
+ Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
+ .font(.system(size: 13)).foregroundStyle(p.subtext)
+ if open && part.totalEx > 0 {
+ ProgressBar(fraction: CGFloat(part.doneEx) / CGFloat(part.totalEx), palette: p, height: 6)
+ .padding(.top, 4)
+ }
+ }
+ }
+ .opacity(open ? 1 : 0.55)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 24)
+ }
+ }
+ }
+
+ private func metric(_ p: Palette, _ label: String, _ value: String) -> some View {
+ CardBox(palette: p) {
+ VStack(alignment: .leading) {
+ Text(label).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
+ Text(value).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
+ }
+ }
+ }
+}
+
+struct ProgressBar: View {
+ let fraction: CGFloat
+ let palette: Palette
+ var height: CGFloat = 8
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Capsule().fill(palette.surface1)
+ Capsule().fill(palette.accent).frame(width: geo.size.width * min(max(fraction, 0), 1))
+ }
+ }
+ .frame(height: height)
+ }
+}
+
+private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum {
+ var sum = ProgressSum()
+ switch s.str("target") {
+ case "roadmap":
+ 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
+ }
+ case "netyears":
+ sum.totalEx = vm.content.netLessons.count
+ sum.doneEx = vm.content.netLessons.filter { vm.progress.netDone($0.int("id")) }.count
+ case "hwmap":
+ sum.totalEx = vm.content.hw.count
+ sum.doneEx = vm.content.hw.filter { vm.progress.hwDone($0.int("id")) }.count
+ case "czechmap":
+ sum.totalEx = vm.content.mluvnice.count
+ sum.doneEx = vm.content.mluvnice.filter { vm.progress.mluvDone($0.int("id")) }.count
+ default:
+ break
+ }
+ return sum
+}
+
+struct SearchOverlay: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ let p = vm.palette
+ let q = normalizeAnswer(vm.searchQuery)
+ let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }.prefix(24)
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ ZStack(alignment: .leading) {
+ if vm.searchQuery.isEmpty {
+ Text(vm.tr("search_placeholder")).foregroundStyle(p.overlay)
+ }
+ TextField("", text: $vm.searchQuery)
+ .foregroundStyle(p.text)
+ .tint(p.accent)
+ }
+ .padding(14)
+ .background(p.mantle, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ PillButton(label: vm.tr("back"), palette: p) { vm.searchOpen = false }
+ }
+ if hits.isEmpty {
+ Text(q.isEmpty ? vm.tr("search_hint") : vm.tr("search_empty")).foregroundStyle(p.subtext)
+ } else {
+ ScrollView {
+ VStack(spacing: 8) {
+ ForEach(Array(hits), id: \.target) { hit in
+ Button {
+ if !hit.locked {
+ vm.goPage(hit.target)
+ vm.searchOpen = false
+ }
+ } label: {
+ CardBox(palette: p) {
+ VStack(alignment: .leading) {
+ Text(hit.title).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text)
+ Text(hit.sub).font(.system(size: 13)).foregroundStyle(p.subtext)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .opacity(hit.locked ? 0.5 : 1)
+ }
+ }
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(16)
+ .background(p.base.opacity(0.96))
+ }
+}
+
+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 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"))")
+ }
+ 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 {
+ let current = vm.content.german.firstIndex { u in
+ u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) }
+ }
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: vm.tr("roadmap_title"), subtitle: vm.tr("roadmap_sub"))
+ .padding(.horizontal, 12)
+ 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: u.str("page"),
+ label: u.str("title"),
+ locked: !unlocked,
+ done: done,
+ current: i == current,
+ finish: i == vm.content.german.count - 1,
+ onClick: unlocked ? { vm.go(.unitMap(u.int("id"))) } : nil
+ )
+ },
+ palette: vm.palette
+ )
+ .padding(.horizontal, 12)
+ }
+ }
+}
+
+struct UnitMapScreen: View {
+ @ObservedObject var vm: AppModel
+ let unitId: Int
+
+ var body: some View {
+ if let u = vm.content.germanUnit(unitId) {
+ let names = u.strs("names")
+ let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) }
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: u.str("title"), subtitle: vm.tr(u.str("sub")))
+ .padding(.horizontal, 12)
+ 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,
+ onClick: { vm.go(.germanEx(unitId, i + 1)) }
+ )
+ },
+ palette: vm.palette
+ )
+ .padding(.horizontal, 12)
+ if u.has("branch") {
+ PrimaryButton(label: vm.tr("Vokabeltraining"), palette: vm.palette) { vm.go(.vocab(unitId)) }
+ .padding(.bottom, 16)
+ }
+ }
+ }
+ }
+}
+
+struct NetYearsScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_years_title"), subtitle: vm.tr("net_years_sub"))
+ .padding(.horizontal, 16)
+ ScrollView {
+ VStack(spacing: 12) {
+ yearCard(vm.tr("net_year1"), vm.tr("net_sub"), true) { vm.go(.netMap) }
+ ForEach(["net_year2", "net_year3", "net_year4"], id: \.self) { key in
+ yearCard(vm.tr(key), vm.tr("net_year_locked_sub"), false) {}
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 16)
+ }
+ }
+ }
+
+ private func yearCard(_ title: String, _ sub: String, _ open: Bool, _ action: @escaping () -> Void) -> some View {
+ let p = vm.palette
+ return Button(action: action) {
+ CardBox(palette: p) {
+ HStack {
+ VStack(alignment: .leading) {
+ Text(title).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text)
+ Text(sub).font(.system(size: 14)).foregroundStyle(p.subtext)
+ }
+ Spacer()
+ if !open { LockIcon(color: p.overlay) }
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(!open)
+ .opacity(open ? 1 : 0.48)
+ }
+}
+
+struct LessonMapScreen: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ let sub: String
+ let nodes: [MapNode]
+
+ var body: some View {
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
+ .padding(.horizontal, 12)
+ PathMap(nodes: nodes, palette: vm.palette)
+ .padding(.horizontal, 12)
+ }
+ }
+}
+
+struct SlidesScreen: View {
+ @ObservedObject var vm: AppModel
+ let title: String
+ let sub: String
+ let slides: [J]
+ var onExercise: () -> Void
+ @State private var idx = 0
+
+ var body: some View {
+ let p = vm.palette
+ if let slide = slides.indices.contains(idx) ? slides[idx] : nil {
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
+ .padding(.horizontal, 16)
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(slide.str("kicker")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
+ Text(slide.str("title")).font(.system(size: 24, weight: .bold)).foregroundStyle(p.text)
+ if let tip = slide.strOrNull("tip") {
+ CardBox(palette: p) { Text(tip).foregroundStyle(p.text) }
+ }
+ ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
+ CardBox(palette: p) { Text(line).font(.system(size: 16)).foregroundStyle(p.text) }
+ }
+ }
+ .padding(.horizontal, 16)
+ }
+ HStack {
+ if idx > 0 {
+ PillButton(label: vm.tr("net_slide_prev"), palette: p) { idx -= 1 }
+ }
+ Spacer()
+ if idx < slides.count - 1 {
+ PrimaryButton(label: vm.tr("net_slide_next"), palette: p) { idx += 1 }
+ } else {
+ PrimaryButton(label: vm.tr("net_slide_start"), palette: p, action: onExercise)
+ }
+ }
+ .padding(12)
+ }
+ }
+ }
+}
+
+struct CzechMapScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ let p = vm.palette
+ let items: [(String, String, Route?)] = [
+ ("L", vm.tr("Literatura"), nil),
+ ("M", vm.tr("Mluvnice"), .mluvnice),
+ ("Č", vm.tr("Maturitní četba"), .readingList),
+ ]
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Český jazyk a literatura"), subtitle: vm.tr("czech_sub"))
+ .padding(.horizontal, 16)
+ ScrollView {
+ VStack(spacing: 12) {
+ ForEach(items, id: \.0) { letter, name, route in
+ Button {
+ if let route { vm.go(route) }
+ } label: {
+ CardBox(palette: p) {
+ HStack(spacing: 14) {
+ ZStack {
+ Circle().fill(route == nil ? p.lockedBg : p.accent).frame(width: 56, height: 56)
+ if route == nil {
+ LockIcon(color: p.overlay)
+ } else {
+ Text(letter).font(.system(size: 20, weight: .heavy)).foregroundStyle(p.onAccent)
+ }
+ }
+ Text(name).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text)
+ Spacer()
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(route == nil)
+ .opacity(route == nil ? 0.48 : 1)
+ }
+ }
+ .padding(.horizontal, 16)
+ }
+ }
+ }
+}
+
+struct BookListScreen: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ let p = vm.palette
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub"))
+ .padding(.horizontal, 16)
+ ScrollView {
+ VStack(spacing: 12) {
+ ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
+ Button { vm.go(.book(b.str("id"))) } label: {
+ CardBox(palette: p) {
+ HStack(spacing: 12) {
+ BookIcon(color: p.text, size: 28)
+ VStack(alignment: .leading) {
+ Text(b.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
+ Text(vm.tr(b.str("subKey"))).font(.system(size: 13)).foregroundStyle(p.subtext)
+ }
+ Spacer()
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 16)
+ }
+ }
+ }
+}
+
+struct BookScreen: View {
+ @ObservedObject var vm: AppModel
+ let id: String
+
+ var body: some View {
+ if let b = vm.content.book(id) {
+ let p = vm.palette
+ VStack(spacing: 0) {
+ PageTop(vm: vm, back: { vm.back() }, title: b.str("title"), subtitle: vm.tr(b.str("subKey")))
+ .padding(.horizontal, 16)
+ ScrollView {
+ VStack(spacing: 12) {
+ Button { vm.go(.bookQuiz(id)) } label: {
+ CardBox(palette: p) {
+ HStack(spacing: 10) {
+ QuizIcon(color: p.text)
+ VStack(alignment: .leading) {
+ Text(vm.tr(b.str("quizTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
+ Text(vm.tr(b.str("quizSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
+ }
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ Button { vm.go(.bookPlot(id)) } label: {
+ CardBox(palette: p) {
+ HStack(spacing: 10) {
+ OrderIcon(color: p.text)
+ VStack(alignment: .leading) {
+ Text(vm.tr(b.str("plotTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
+ Text(vm.tr(b.str("plotSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
+ }
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in
+ CardBox(palette: p) {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 8) {
+ noteIcon(note.str("icon"), p.text)
+ Text(note.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
+ }
+ ForEach(note.strs("lines"), id: \.self) { line in
+ Text("• \(line)").font(.system(size: 15)).foregroundStyle(p.text)
+ }
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 20)
+ }
+ }
+ }
+ }
+}
diff --git a/ios/Maturita/Update/Updater.swift b/ios/Maturita/Update/Updater.swift
new file mode 100644
index 0000000..8c6949a
--- /dev/null
+++ b/ios/Maturita/Update/Updater.swift
@@ -0,0 +1,113 @@
+import Foundation
+import UIKit
+
+struct UpdateState {
+ var status = "idle"
+ var remote: String?
+ var messageKey = "update_current"
+ var messageArg: String? = String(AppConfig.commit.prefix(7))
+ var canInstall = false
+}
+
+final class Updater {
+ private let repo = AppConfig.updateRepo
+ private let branch = AppConfig.updateBranch
+
+ func check(interactive: Bool, cb: @escaping (UpdateState) -> Void) {
+ if AppConfig.commit == "dev" {
+ emit(cb, UpdateState(status: "dev", messageKey: "update_err_devbuild", messageArg: nil))
+ return
+ }
+ emit(cb, UpdateState(status: "checking", messageKey: "update_checking", messageArg: nil))
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
+ let remote: String
+ if let sha = self.atomMainSha(atom) {
+ remote = sha
+ } else if let tip = self.atomTip(atom) {
+ remote = try self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION").trimmingCharacters(in: .whitespacesAndNewlines)
+ } else {
+ throw URLError(.cannotParseResponse)
+ }
+ let local = AppConfig.commit
+ if self.sameCommit(remote, local) {
+ self.emit(cb, UpdateState(status: "ok", remote: remote, messageKey: "update_uptodate", messageArg: String(remote.prefix(7))))
+ } else {
+ self.emit(cb, UpdateState(status: "available", remote: remote, messageKey: "update_available", messageArg: String(remote.prefix(7)), canInstall: true))
+ }
+ } catch {
+ self.emit(cb, UpdateState(status: "error", messageKey: "update_err_network", messageArg: nil))
+ }
+ }
+ }
+
+ func install(cb: @escaping (UpdateState) -> Void) {
+ emit(cb, UpdateState(status: "downloading", messageKey: "update_downloading", messageArg: nil))
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
+ let tip = self.atomTip(atom) ?? ""
+ let page = "https://github.com/\(self.repo)/raw/main/installers/maturita-ios.html"
+ DispatchQueue.main.async {
+ if let url = URL(string: page) {
+ UIApplication.shared.open(url)
+ }
+ }
+ self.emit(cb, UpdateState(status: "staged", remote: tip, messageKey: "update_ios_sideload", messageArg: String(tip.prefix(7))))
+ } catch {
+ self.emit(cb, UpdateState(status: "error", messageKey: "update_err_download", messageArg: nil))
+ }
+ }
+ }
+
+ private func emit(_ cb: @escaping (UpdateState) -> Void, _ state: UpdateState) {
+ DispatchQueue.main.async { cb(state) }
+ }
+
+ private func atomTip(_ atom: String) -> String? {
+ if let m = atom.range(of: #"Commit/([0-9a-f]{40})"#, options: [.regularExpression, .caseInsensitive]) {
+ let s = String(atom[m])
+ return s.split(separator: "/").last.map(String.init)
+ }
+ if let m = atom.range(of: #"/commit/([0-9a-f]{7,40})"#, options: [.regularExpression, .caseInsensitive]) {
+ return String(atom[m]).split(separator: "/").last.map(String.init)
+ }
+ return nil
+ }
+
+ private func atomMainSha(_ atom: String) -> String? {
+ guard let regex = try? NSRegularExpression(pattern: #"Build from ([0-9a-f]{7,40})"#, options: .caseInsensitive),
+ let match = regex.firstMatch(in: atom, range: NSRange(atom.startIndex..., in: atom)),
+ let range = Range(match.range(at: 1), in: atom)
+ else { return nil }
+ return String(atom[range])
+ }
+
+ private func sameCommit(_ a: String, _ b: String) -> Bool {
+ let n = min(a.count, b.count)
+ return n >= 7 && a.prefix(n).lowercased() == b.prefix(n).lowercased()
+ }
+
+ private func get(_ url: String) throws -> String {
+ guard let u = URL(string: url) else { throw URLError(.badURL) }
+ var req = URLRequest(url: u, timeoutInterval: 15)
+ req.setValue("maturita.c-ios", forHTTPHeaderField: "User-Agent")
+ req.setValue("*/*", forHTTPHeaderField: "Accept")
+ let sem = DispatchSemaphore(value: 0)
+ var result: Result = .failure(URLError(.unknown))
+ URLSession.shared.dataTask(with: req) { data, resp, err in
+ if let err { result = .failure(err) }
+ else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ result = .failure(URLError(.badServerResponse))
+ } else if let data, let text = String(data: data, encoding: .utf8) {
+ result = .success(text)
+ } else {
+ result = .failure(URLError(.cannotDecodeContentData))
+ }
+ sem.signal()
+ }.resume()
+ _ = sem.wait(timeout: .now() + 20)
+ return try result.get()
+ }
+}
diff --git a/scripts/bundle-ios.sh b/scripts/bundle-ios.sh
new file mode 100755
index 0000000..7204b55
--- /dev/null
+++ b/scripts/bundle-ios.sh
@@ -0,0 +1,38 @@
+#!/bin/sh
+# Build an unsigned iOS IPA for sideloading (Xcode / AltStore / Sideloadly).
+
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+cd "$root"
+
+python3 scripts/extract-android-content.py
+
+export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
+COMMIT="${COMMIT:-$(git -C "$root" rev-parse HEAD 2>/dev/null || echo dev)}"
+dd="$root/ios/DerivedData"
+
+rm -rf "$root/ios/Payload"
+mkdir -p "$dd" "$root/dist-ios"
+
+xcodebuild \
+ -project ios/Maturita.xcodeproj \
+ -scheme Maturita \
+ -configuration Release \
+ -sdk iphoneos \
+ -destination 'generic/platform=iOS' \
+ -derivedDataPath "$dd" \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ COMMIT="$COMMIT"
+
+app=$(find "$dd" -name 'Maturita.app' -path '*/Release-iphoneos/*' | head -n 1)
+if [ -z "$app" ]; then
+ echo "Maturita.app not found" >&2
+ exit 1
+fi
+
+mkdir -p "$root/ios/Payload"
+cp -R "$app" "$root/ios/Payload/Maturita.app"
+(cd "$root/ios" && zip -r -q "$root/dist-ios/maturita-ios.ipa" Payload)
+echo "wrote $root/dist-ios/maturita-ios.ipa"
diff --git a/scripts/extract-android-content.py b/scripts/extract-android-content.py
index 901b040..ceb2581 100644
--- a/scripts/extract-android-content.py
+++ b/scripts/extract-android-content.py
@@ -10,7 +10,11 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
-OUT = ROOT / "android" / "app" / "src" / "main" / "assets" / "content.json"
+OUTS = [
+ ROOT / "android" / "app" / "src" / "main" / "assets" / "content.json",
+ ROOT / "ios" / "Maturita" / "content.json",
+]
+OUT = OUTS[0]
IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
@@ -1223,8 +1227,10 @@ def main() -> int:
else:
german_units[2]["exercises"]["5"]["bank"] = raw[0]
- OUT.parent.mkdir(parents=True, exist_ok=True)
- OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ text = json.dumps(payload, ensure_ascii=False, indent=2)
+ for dest in OUTS:
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_text(text, encoding="utf-8")
def count(path, pred):
node = payload
@@ -1232,7 +1238,7 @@ def main() -> int:
node = node[p]
return sum(1 for x in node if pred(x))
- print(f"wrote {OUT}")
+ print("wrote " + ", ".join(str(dest) for dest in OUTS))
print(f" i18n keys: {len(i18n)}")
print(f" german unlocked exercises: "
f"{sum(len(u.get('exercises', {})) for u in german_units if u['unlocked'])}")
diff --git a/src/i18n.c b/src/i18n.c
index 3a69e7c..e2a160e 100644
--- a/src/i18n.c
+++ b/src/i18n.c
@@ -61,6 +61,14 @@ const TrEntry tr_ui[] = {
"stejný obsah jako na počítači.",
"Exam practice on your phone. Czech and English, "
"the same content as on the computer."},
+ {"welcome_body_ios",
+ "Procvičování k maturitě v iPhonu. Česky i anglicky, "
+ "stejný obsah jako na počítači.",
+ "Exam practice on iPhone. Czech and English, "
+ "the same content as on the computer."},
+ {"update_ios_sideload",
+ "Nový build je na GitHubu. Nainstalujte ho znovu přes Xcode nebo AltStore (%s).",
+ "A newer build is on GitHub. Reinstall it with Xcode or AltStore (%s)."},
{"welcome_feat1_title", "Efektivní procvičování", "Focused practice"},
{"welcome_feat1_body",
"Cvičení, nápovědy a vokabeltraining. Aplikace si pamatuje, kde jste skončili.",