mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Add a SwiftUI iOS app that plays the same maturita content.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3f88e55f33
commit
6c7336c7bb
33 changed files with 4498 additions and 10 deletions
22
.github/workflows/build.yml
vendored
22
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
6
Makefile
6
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
|
||||
|
|
|
|||
32
README.md
32
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).
|
||||
|
|
|
|||
BIN
installers/maturita-installer-ios.zip
Normal file
BIN
installers/maturita-installer-ios.zip
Normal file
Binary file not shown.
72
installers/maturita-ios.html
Normal file
72
installers/maturita-ios.html
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>maturita.C pro iOS</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 28rem;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1.2rem;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
a.btn {
|
||||
display: block;
|
||||
text-align: center;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
padding: 1rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
p { color: #444; line-height: 1.45; }
|
||||
code { font-size: 0.95em; }
|
||||
ol { color: #444; line-height: 1.5; padding-left: 1.2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>maturita.C pro iPhone</h1>
|
||||
<p>
|
||||
Apple nepovolí instalaci jako Android APK. Nejjednodušší cesta je Mac
|
||||
s Xcode a vlastní iPhone (bezplatný Apple účet stačí na 7 dní).
|
||||
</p>
|
||||
<ol>
|
||||
<li>Naklonuj <a href="https://github.com/pepaskopec-blip/maturita.c">maturita.c</a></li>
|
||||
<li>Otevři <code>ios/Maturita.xcodeproj</code></li>
|
||||
<li>V Signing vyber svůj Team</li>
|
||||
<li>Připoj iPhone a stiskni Run</li>
|
||||
</ol>
|
||||
<p>
|
||||
IPA z větve <code>builds</code> jde nahrát i přes AltStore nebo Sideloadly
|
||||
(unsigned balíček, potřebuješ nástroj na sideload).
|
||||
</p>
|
||||
<p>
|
||||
<a class="btn" id="dl"
|
||||
href="https://raw.githubusercontent.com/pepaskopec-blip/maturita.c/refs/heads/builds/maturita-ios.ipa">
|
||||
Stáhnout IPA
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Když odkaz hlásí 404, otevři
|
||||
<a href="https://github.com/pepaskopec-blip/maturita.c/tree/builds">větev builds</a>
|
||||
a stáhni <code>maturita-ios.ipa</code> odtud.
|
||||
</p>
|
||||
<script>
|
||||
(function () {
|
||||
var repo = "pepaskopec-blip/maturita.c";
|
||||
fetch("https://api.github.com/repos/" + repo + "/commits/builds")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.sha) {
|
||||
document.getElementById("dl").href =
|
||||
"https://raw.githubusercontent.com/" + repo + "/" + d.sha + "/maturita-ios.ipa";
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
370
ios/Maturita.xcodeproj/project.pbxproj
Normal file
370
ios/Maturita.xcodeproj/project.pbxproj
Normal file
|
|
@ -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 = "<group>"; };
|
||||
A201000000000000000002 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000003 /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000004 /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000005 /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000006 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000007 /* Answers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Answers.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000008 /* ProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressStore.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A201000000000000000017 /* content.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = content.json; sourceTree = "<group>"; };
|
||||
A201000000000000000018 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
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 = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
A501000000000000000003 /* Data */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A201000000000000000004 /* JSON.swift */,
|
||||
A201000000000000000005 /* Content.swift */,
|
||||
A201000000000000000006 /* Theme.swift */,
|
||||
A201000000000000000007 /* Answers.swift */,
|
||||
A201000000000000000008 /* ProgressStore.swift */,
|
||||
);
|
||||
path = Data;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A501000000000000000004 /* Update */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A201000000000000000009 /* Updater.swift */,
|
||||
);
|
||||
path = Update;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
A501000000000000000006 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A301000000000000000001 /* Maturita.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
7
ios/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A601000000000000000001"
|
||||
BuildableName = "Maturita.app"
|
||||
BlueprintName = "Maturita"
|
||||
ReferencedContainer = "container:Maturita.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeFlags = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A601000000000000000001"
|
||||
BuildableName = "Maturita.app"
|
||||
BlueprintName = "Maturita"
|
||||
ReferencedContainer = "container:Maturita.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeFlags = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A601000000000000000001"
|
||||
BuildableName = "Maturita.app"
|
||||
BlueprintName = "Maturita"
|
||||
ReferencedContainer = "container:Maturita.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
113
ios/Maturita/AppModel.swift
Normal file
113
ios/Maturita/AppModel.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
ios/Maturita/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
ios/Maturita/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 365 KiB |
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
ios/Maturita/Assets.xcassets/Contents.json
Normal file
6
ios/Maturita/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
60
ios/Maturita/Data/Answers.swift
Normal file
60
ios/Maturita/Data/Answers.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import Foundation
|
||||
|
||||
func normalizeAnswer(_ input: String?) -> String {
|
||||
var pre = ""
|
||||
for ch in (input ?? "").lowercased() {
|
||||
switch ch {
|
||||
case "ä": pre += "ae"
|
||||
case "ö": pre += "oe"
|
||||
case "ü": pre += "ue"
|
||||
case "ß": pre += "ss"
|
||||
default: pre.append(ch)
|
||||
}
|
||||
}
|
||||
let decomp = pre.decomposedStringWithCanonicalMapping
|
||||
var out = ""
|
||||
var prevSpace = false
|
||||
for scalar in decomp.unicodeScalars {
|
||||
let v = scalar.value
|
||||
if (0x0300...0x036F).contains(v) || (0x1AB0...0x1AFF).contains(v) ||
|
||||
(0x1DC0...0x1DFF).contains(v) || (0x20D0...0x20FF).contains(v) ||
|
||||
(0xFE20...0xFE2F).contains(v) {
|
||||
continue
|
||||
}
|
||||
if CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
if !out.isEmpty && !prevSpace { out.append(" ") }
|
||||
prevSpace = true
|
||||
continue
|
||||
}
|
||||
if !CharacterSet.alphanumerics.contains(scalar) { continue }
|
||||
prevSpace = false
|
||||
out.unicodeScalars.append(scalar)
|
||||
}
|
||||
return out.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
func answerAccepts(_ selNorm: String, _ ans: String?) -> Bool {
|
||||
guard let ans, !ans.isEmpty else { return false }
|
||||
return ans.split(separator: "|").contains { normalizeAnswer(String($0)) == selNorm }
|
||||
}
|
||||
|
||||
func answerIncomplete(_ selNorm: String, _ ans: String?) -> Bool {
|
||||
guard !selNorm.isEmpty, let ans, !ans.isEmpty else { return false }
|
||||
for part in ans.split(separator: "|") {
|
||||
let a = normalizeAnswer(String(part))
|
||||
if a.hasPrefix(selNorm + " ") && a.count > selNorm.count + 1 { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func netTxtEq(_ a: String?, _ b: String?) -> Bool {
|
||||
guard let a, let b else { return a == b }
|
||||
let x = a.filter { !$0.isWhitespace }.lowercased()
|
||||
let y = b.filter { !$0.isWhitespace }.lowercased()
|
||||
return x == y
|
||||
}
|
||||
|
||||
func hasUmlaut(_ s: String?) -> Bool {
|
||||
guard let s else { return false }
|
||||
return s.contains(where: { "äöüÄÖÜß".contains($0) })
|
||||
}
|
||||
95
ios/Maturita/Data/Content.swift
Normal file
95
ios/Maturita/Data/Content.swift
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import Foundation
|
||||
|
||||
enum AppConfig {
|
||||
static var commit: String {
|
||||
let raw = (Bundle.main.object(forInfoDictionaryKey: "GITCommit") as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return raw.isEmpty || raw.hasPrefix("$(") ? "dev" : raw
|
||||
}
|
||||
|
||||
static var versionName: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ios-1"
|
||||
}
|
||||
|
||||
static var updateRepo: String {
|
||||
(Bundle.main.object(forInfoDictionaryKey: "UPDATE_REPO") as? String)
|
||||
?? "pepaskopec-blip/maturita.c"
|
||||
}
|
||||
|
||||
static var updateBranch: String {
|
||||
(Bundle.main.object(forInfoDictionaryKey: "UPDATE_BRANCH") as? String) ?? "builds"
|
||||
}
|
||||
}
|
||||
|
||||
final class Content {
|
||||
let raw: J
|
||||
let i18n: [String: (String?, String?)]
|
||||
let subjects: [J]
|
||||
let german: [J]
|
||||
let net: J
|
||||
let netLessons: [J]
|
||||
let netTasks: [J]
|
||||
let netAnswers: [[J]]
|
||||
let hw: [J]
|
||||
let mluvnice: [J]
|
||||
let books: [J]
|
||||
|
||||
init(_ root: [String: Any]) {
|
||||
raw = J(root)
|
||||
let obj = root["i18n"] as? [String: Any] ?? [:]
|
||||
var map: [String: (String?, String?)] = [:]
|
||||
for (key, value) in obj {
|
||||
let e = J(value)
|
||||
let cs = e.strOrNull("cs")
|
||||
let en = e.strOrNull("en")
|
||||
map[key] = (cs, en)
|
||||
}
|
||||
i18n = map
|
||||
subjects = raw.arr("subjects")
|
||||
german = raw.arr("german")
|
||||
net = raw.obj("net") ?? J([:])
|
||||
netLessons = net.arr("lessons")
|
||||
netTasks = net.arr("tasks")
|
||||
if let a = net.o["answers"] as? [Any] {
|
||||
netAnswers = a.map { block in
|
||||
guard let rows = block as? [Any] else { return [] }
|
||||
return rows.compactMap { item in
|
||||
item is [String: Any] ? J(item) : nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
netAnswers = []
|
||||
}
|
||||
hw = raw.arr("hw")
|
||||
mluvnice = raw.arr("mluvnice")
|
||||
books = raw.arr("books")
|
||||
}
|
||||
|
||||
func germanUnit(_ id: Int) -> J? { german.first { $0.int("id") == id } }
|
||||
func netLesson(_ id: Int) -> J? { netLessons.first { $0.int("id") == id } }
|
||||
func hwLesson(_ id: Int) -> J? { hw.first { $0.int("id") == id } }
|
||||
func mluv(_ n: Int) -> J? { mluvnice.first { $0.int("id") == n } }
|
||||
func book(_ id: String) -> J? { books.first { $0.str("id") == id } }
|
||||
|
||||
func tr(_ key: String?, lang: UiLang) -> String {
|
||||
guard let key, !key.isEmpty else { return "" }
|
||||
let hit = i18n[key]
|
||||
if lang == .en { return hit?.1 ?? key }
|
||||
return hit?.0 ?? key
|
||||
}
|
||||
|
||||
func fmt(_ key: String, lang: UiLang, _ args: [CVarArg]) -> String {
|
||||
let template = tr(key, lang: lang).replacingOccurrences(of: "%s", with: "%@")
|
||||
return String(format: template, arguments: args)
|
||||
}
|
||||
|
||||
static func load() -> Content {
|
||||
guard let url = Bundle.main.url(forResource: "content", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else {
|
||||
return Content([:])
|
||||
}
|
||||
return Content(obj)
|
||||
}
|
||||
}
|
||||
102
ios/Maturita/Data/JSON.swift
Normal file
102
ios/Maturita/Data/JSON.swift
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import Foundation
|
||||
|
||||
struct J {
|
||||
let o: [String: Any]
|
||||
|
||||
init(_ obj: Any?) {
|
||||
o = obj as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
func has(_ key: String) -> Bool {
|
||||
guard let value = o[key], !(value is NSNull) else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
func str(_ key: String, _ fallback: String = "") -> String {
|
||||
guard has(key) else { return fallback }
|
||||
if let s = o[key] as? String { return s }
|
||||
if let n = o[key] as? NSNumber { return n.stringValue }
|
||||
return fallback
|
||||
}
|
||||
|
||||
func strOrNull(_ key: String) -> String? {
|
||||
guard has(key) else { return nil }
|
||||
if let s = o[key] as? String, !s.isEmpty, s != "null" { return s }
|
||||
return nil
|
||||
}
|
||||
|
||||
func bool(_ key: String, _ fallback: Bool = false) -> Bool {
|
||||
if let b = o[key] as? Bool { return b }
|
||||
if let n = o[key] as? NSNumber { return n.boolValue }
|
||||
return fallback
|
||||
}
|
||||
|
||||
func int(_ key: String, _ fallback: Int = 0) -> Int {
|
||||
if let n = o[key] as? Int { return n }
|
||||
if let n = o[key] as? NSNumber { return n.intValue }
|
||||
if let s = o[key] as? String, let n = Int(s) { return n }
|
||||
return fallback
|
||||
}
|
||||
|
||||
func obj(_ key: String) -> J? {
|
||||
guard let value = o[key], !(value is NSNull) else { return nil }
|
||||
if value is [String: Any] { return J(value) }
|
||||
return nil
|
||||
}
|
||||
|
||||
func exercise(_ n: Int) -> J? {
|
||||
guard let ex = o["exercises"] as? [String: Any] else { return nil }
|
||||
return J(ex["\(n)"])
|
||||
}
|
||||
|
||||
func arr(_ key: String) -> [J] {
|
||||
guard let a = o[key] as? [Any] else { return [] }
|
||||
return a.compactMap { item in
|
||||
item is [String: Any] ? J(item) : nil
|
||||
}
|
||||
}
|
||||
|
||||
func strs(_ key: String) -> [String] {
|
||||
guard let a = o[key] as? [Any] else { return [] }
|
||||
return a.compactMap { item in
|
||||
if item is NSNull { return nil }
|
||||
if let s = item as? String, !s.isEmpty { return s }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func strsOrEmpty(_ key: String) -> [String?] {
|
||||
guard let a = o[key] as? [Any] else { return [] }
|
||||
return a.map { item in
|
||||
if item is NSNull { return nil }
|
||||
return item as? String
|
||||
}
|
||||
}
|
||||
|
||||
func intRows(_ key: String) -> [[String]] {
|
||||
guard let a = o[key] as? [Any] else { return [] }
|
||||
return a.map { row in
|
||||
guard let cells = row as? [Any] else { return [] }
|
||||
return cells.map { cell in
|
||||
if let s = cell as? String { return s }
|
||||
if let n = cell as? NSNumber { return n.stringValue }
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rawArray(_ key: String) -> [Any] {
|
||||
o[key] as? [Any] ?? []
|
||||
}
|
||||
|
||||
func answerList() -> [String] {
|
||||
if let a = o["answers"] as? [Any] {
|
||||
return a.compactMap { item in
|
||||
if item is NSNull { return nil }
|
||||
if let s = item as? String, !s.isEmpty { return s }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return [strOrNull("answers")].compactMap { $0 }
|
||||
}
|
||||
}
|
||||
81
ios/Maturita/Data/ProgressStore.swift
Normal file
81
ios/Maturita/Data/ProgressStore.swift
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import Foundation
|
||||
|
||||
final class ProgressStore {
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
var themeId: ThemeId {
|
||||
get { ThemeId(rawValue: defaults.integer(forKey: "theme")) ?? .catppuccin }
|
||||
set { defaults.set(newValue.rawValue, forKey: "theme") }
|
||||
}
|
||||
|
||||
var mode: ColorMode {
|
||||
get { defaults.string(forKey: "mode") == "dark" ? .dark : .light }
|
||||
set { defaults.set(newValue == .light ? "light" : "dark", forKey: "mode") }
|
||||
}
|
||||
|
||||
var lang: UiLang {
|
||||
get { defaults.string(forKey: "lang") == "en" ? .en : .cs }
|
||||
set { defaults.set(newValue == .en ? "en" : "cs", forKey: "lang") }
|
||||
}
|
||||
|
||||
func germanDone(_ unit: Int, _ ex: Int) -> Bool { defaults.bool(forKey: "g.\(unit).\(ex)") }
|
||||
func markGerman(_ unit: Int, _ ex: Int) { defaults.set(true, forKey: "g.\(unit).\(ex)") }
|
||||
func vocabDone(_ unit: Int) -> Bool { defaults.bool(forKey: "g.\(unit).vocab") }
|
||||
func markVocab(_ unit: Int) { defaults.set(true, forKey: "g.\(unit).vocab") }
|
||||
|
||||
func netDone(_ id: Int) -> Bool { defaults.bool(forKey: "net.\(id)") }
|
||||
func markNet(_ id: Int) { defaults.set(true, forKey: "net.\(id)") }
|
||||
|
||||
func hwDone(_ id: Int) -> Bool { defaults.bool(forKey: "hw.\(id)") }
|
||||
func markHw(_ id: Int) { defaults.set(true, forKey: "hw.\(id)") }
|
||||
|
||||
func mluvDone(_ n: Int) -> Bool { defaults.bool(forKey: "mluv.\(n)") }
|
||||
func markMluv(_ n: Int) { defaults.set(true, forKey: "mluv.\(n)") }
|
||||
|
||||
func bookQuiz(_ id: String) -> Bool { defaults.bool(forKey: "book.\(id).quiz") }
|
||||
func markBookQuiz(_ id: String) { defaults.set(true, forKey: "book.\(id).quiz") }
|
||||
func bookPlot(_ id: String) -> Bool { defaults.bool(forKey: "book.\(id).plot") }
|
||||
func markBookPlot(_ id: String) { defaults.set(true, forKey: "book.\(id).plot") }
|
||||
}
|
||||
|
||||
struct ProgressSum {
|
||||
var doneEx = 0
|
||||
var totalEx = 0
|
||||
var doneUnits = 0
|
||||
var openUnits = 0
|
||||
}
|
||||
|
||||
func summarize(_ content: Content, _ p: ProgressStore) -> ProgressSum {
|
||||
var sum = ProgressSum()
|
||||
for u in content.german where u.bool("unlocked") {
|
||||
let id = u.int("id")
|
||||
let names = u.strs("names")
|
||||
sum.totalEx += names.count
|
||||
sum.openUnits += 1
|
||||
let done = names.indices.filter { p.germanDone(id, $0 + 1) }.count
|
||||
sum.doneEx += done
|
||||
if !names.isEmpty && done == names.count { sum.doneUnits += 1 }
|
||||
}
|
||||
for l in content.netLessons {
|
||||
sum.totalEx += 1
|
||||
sum.openUnits += 1
|
||||
if p.netDone(l.int("id")) {
|
||||
sum.doneEx += 1
|
||||
sum.doneUnits += 1
|
||||
}
|
||||
}
|
||||
for l in content.hw {
|
||||
sum.totalEx += 1
|
||||
sum.openUnits += 1
|
||||
if p.hwDone(l.int("id")) {
|
||||
sum.doneEx += 1
|
||||
sum.doneUnits += 1
|
||||
}
|
||||
}
|
||||
sum.totalEx += content.mluvnice.count
|
||||
sum.openUnits += 1
|
||||
let md = content.mluvnice.filter { p.mluvDone($0.int("id")) }.count
|
||||
sum.doneEx += md
|
||||
if md == content.mluvnice.count && !content.mluvnice.isEmpty { sum.doneUnits += 1 }
|
||||
return sum
|
||||
}
|
||||
173
ios/Maturita/Data/Theme.swift
Normal file
173
ios/Maturita/Data/Theme.swift
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import SwiftUI
|
||||
|
||||
enum ThemeId: Int, CaseIterable {
|
||||
case catppuccin, nord, dracula, rosePine, ocean, gruvbox, solarized, everforest, monokai, oneDark
|
||||
}
|
||||
|
||||
enum ColorMode {
|
||||
case dark, light
|
||||
}
|
||||
|
||||
enum UiLang {
|
||||
case cs, en
|
||||
}
|
||||
|
||||
let themeNames = [
|
||||
"Catppuccin", "Nord", "Dracula", "Rose Pine", "Ocean",
|
||||
"Gruvbox", "Solarized", "Everforest", "Monokai", "One Dark",
|
||||
]
|
||||
|
||||
struct Palette {
|
||||
let crust, base, mantle, surface0, surface1, surface2: Color
|
||||
let overlay, text, subtext: Color
|
||||
let accent, accent2, accent3: Color
|
||||
let success, success2, warning, error: Color
|
||||
let onAccent, node, lockedBg, lockedBorder, rail: Color
|
||||
let finishDark, finishLight, finishStroke: Color
|
||||
}
|
||||
|
||||
private func hx(_ v: UInt32) -> Color {
|
||||
Color(
|
||||
red: Double((v >> 16) & 0xFF) / 255,
|
||||
green: Double((v >> 8) & 0xFF) / 255,
|
||||
blue: Double(v & 0xFF) / 255
|
||||
)
|
||||
}
|
||||
|
||||
private func pal(_ c: [UInt32]) -> Palette {
|
||||
Palette(
|
||||
crust: hx(c[0]), base: hx(c[1]), mantle: hx(c[2]), surface0: hx(c[3]),
|
||||
surface1: hx(c[4]), surface2: hx(c[5]), overlay: hx(c[6]), text: hx(c[7]),
|
||||
subtext: hx(c[8]), accent: hx(c[9]), accent2: hx(c[10]), accent3: hx(c[11]),
|
||||
success: hx(c[12]), success2: hx(c[13]), warning: hx(c[14]), error: hx(c[15]),
|
||||
onAccent: hx(c[16]), node: hx(c[17]), lockedBg: hx(c[18]),
|
||||
lockedBorder: hx(c[19]), rail: hx(c[20]), finishDark: hx(c[21]),
|
||||
finishLight: hx(c[22]), finishStroke: hx(c[23])
|
||||
)
|
||||
}
|
||||
|
||||
func themePalette(_ id: ThemeId, _ mode: ColorMode) -> Palette {
|
||||
let light = mode == .light
|
||||
switch id {
|
||||
case .catppuccin:
|
||||
return light ? pal([
|
||||
0xf3eee6, 0xfaf7f2, 0xffffff, 0xffffff, 0xf0ebe3, 0xe5dfd4,
|
||||
0x8a8378, 0x1a1a1a, 0x5c574f, 0xffcc00, 0x0071e3, 0x2997ff,
|
||||
0x1f8a4c, 0x1a7f4a, 0xe6a800, 0xd32f2f, 0x1a1a1a,
|
||||
0xffffff, 0xf0ebe3, 0xd5cfc4, 0xe5dfd4, 0xf3eee6, 0xd5cfc4, 0x8a8378,
|
||||
]) : pal([
|
||||
0x000000, 0x000000, 0x111111, 0x1d1d1f, 0x2c2c2e, 0x3a3a3c,
|
||||
0x86868b, 0xf5f5f7, 0xa1a1a6, 0xffd60a, 0x2997ff, 0x64d2ff,
|
||||
0x30d158, 0x64d2ff, 0xffd60a, 0xff453a, 0x1d1d1f,
|
||||
0x1d1d1f, 0x111111, 0x3a3a3c, 0x2c2c2e, 0x000000, 0x3a3a3c, 0x86868b,
|
||||
])
|
||||
case .nord:
|
||||
return light ? pal([
|
||||
0xd8dee9, 0xeceff4, 0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xb0b8c8,
|
||||
0x7b88a1, 0x2e3440, 0x4c566a, 0x5e81ac, 0x81a1c1, 0x5e81ac,
|
||||
0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0xeceff4,
|
||||
0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xd8dee9, 0xd8dee9, 0xc7ceda, 0x7b88a1,
|
||||
]) : pal([
|
||||
0x2e3440, 0x3b4252, 0x2e3440, 0x434c5e, 0x4c566a, 0x616e88,
|
||||
0x7b88a1, 0xeceff4, 0xd8dee9, 0x88c0d0, 0x81a1c1, 0x5e81ac,
|
||||
0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0x2e3440,
|
||||
0x434c5e, 0x3b4252, 0x4c566a, 0x2e3440, 0x2e3440, 0x434c5e, 0x4c566a,
|
||||
])
|
||||
case .dracula:
|
||||
return light ? pal([
|
||||
0xd5d7e0, 0xf8f8f2, 0xefefea, 0xe2e2d8, 0xd0d0c4, 0xbfbfb2,
|
||||
0x9a9a8c, 0x282a36, 0x44475a, 0x7c3aed, 0xd946a6, 0x0891b2,
|
||||
0x16a34a, 0x0e7490, 0xca8a04, 0xdc2626, 0xf8f8f2,
|
||||
0xefefea, 0xe2e2d8, 0xd0d0c4, 0xe2e2d8, 0xd5d7e0, 0xd0d0c4, 0x9a9a8c,
|
||||
]) : pal([
|
||||
0x191a21, 0x282a36, 0x21222c, 0x44475a, 0x6272a4, 0x707eb0,
|
||||
0x6272a4, 0xf8f8f2, 0xbfbfb2, 0xbd93f9, 0xff79c6, 0x8be9fd,
|
||||
0x50fa7b, 0x8be9fd, 0xf1fa8c, 0xff5555, 0x282a36,
|
||||
0x383a4a, 0x2f3240, 0x44475a, 0x21222c, 0x191a21, 0x383a4a, 0x6272a4,
|
||||
])
|
||||
case .rosePine:
|
||||
return light ? pal([
|
||||
0xf2e9e1, 0xfaf4ed, 0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xcecacd,
|
||||
0x9893a5, 0x575279, 0x797593, 0x907aa9, 0xd7827e, 0x56949f,
|
||||
0x286983, 0x56949f, 0xea9d34, 0xb4637a, 0xfaf4ed,
|
||||
0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xf2e9e1, 0xf2e9e1, 0xdfdad9, 0x9893a5,
|
||||
]) : pal([
|
||||
0x191724, 0x1f1d2e, 0x1f1d2e, 0x26233a, 0x403d52, 0x524f67,
|
||||
0x6e6a86, 0xe0def4, 0x908caa, 0xc4a7e7, 0xebbcba, 0x9ccfd8,
|
||||
0x31748f, 0x9ccfd8, 0xf6c177, 0xeb6f92, 0x191724,
|
||||
0x26233a, 0x21202e, 0x403d52, 0x191724, 0x191724, 0x26233a, 0x524f67,
|
||||
])
|
||||
case .ocean:
|
||||
return light ? pal([
|
||||
0xd6dae8, 0xe1e2ec, 0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0x9aa0b5,
|
||||
0x7a8199, 0x2e3446, 0x4a5168, 0x2e7de9, 0x7847bd, 0x007197,
|
||||
0x387068, 0x007197, 0x8c6c3e, 0xc64343, 0xe1e2ec,
|
||||
0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0xc8c9d6, 0xd6dae8, 0xb4b6c5, 0x7a8199,
|
||||
]) : pal([
|
||||
0x16161e, 0x1a1b26, 0x16161e, 0x24283b, 0x414868, 0x565f89,
|
||||
0x565f89, 0xc0caf5, 0xa9b1d6, 0x7aa2f7, 0xbb9af7, 0x7dcfff,
|
||||
0x9ece6a, 0x73daca, 0xe0af68, 0xf7768e, 0x1a1b26,
|
||||
0x24283b, 0x1f2335, 0x3b4261, 0x16161e, 0x16161e, 0x24283b, 0x414868,
|
||||
])
|
||||
case .gruvbox:
|
||||
return light ? pal([
|
||||
0xebdbb2, 0xfbf1c7, 0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xbdae93,
|
||||
0x928374, 0x3c3836, 0x504945, 0xd65d0e, 0xb16286, 0x076678,
|
||||
0x79740e, 0x427b58, 0xb57614, 0x9d0006, 0xfbf1c7,
|
||||
0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xebdbb2, 0xebdbb2, 0xd5c4a1, 0x928374,
|
||||
]) : pal([
|
||||
0x1d2021, 0x282828, 0x1d2021, 0x3c3836, 0x504945, 0x665c54,
|
||||
0x928374, 0xebdbb2, 0xa89984, 0xfe8019, 0xd3869b, 0x83a598,
|
||||
0xb8bb26, 0x8ec07c, 0xfabd2f, 0xfb4934, 0x1d2021,
|
||||
0x3c3836, 0x32302f, 0x504945, 0x1d2021, 0x1d2021, 0x3c3836, 0x504945,
|
||||
])
|
||||
case .solarized:
|
||||
return light ? pal([
|
||||
0xeee8d5, 0xfdf6e3, 0xeee8d5, 0xeee8d5, 0xe2dbc6, 0xc9c0a6,
|
||||
0x657b83, 0x586e75, 0x657b83, 0x268bd2, 0x6c71c4, 0x2aa198,
|
||||
0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0xfdf6e3,
|
||||
0xeee8d5, 0xe6dfc8, 0x93a1a1, 0xeee8d5, 0xeee8d5, 0x93a1a1, 0x586e75,
|
||||
]) : pal([
|
||||
0x002b36, 0x073642, 0x002b36, 0x586e75, 0x657b83, 0x839496,
|
||||
0x93a1a1, 0xfdf6e3, 0x93a1a1, 0x268bd2, 0x6c71c4, 0x2aa198,
|
||||
0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0x002b36,
|
||||
0x073642, 0x002b36, 0x586e75, 0x002b36, 0x002b36, 0x073642, 0x586e75,
|
||||
])
|
||||
case .everforest:
|
||||
return light ? pal([
|
||||
0xe6e2cc, 0xfdf6e3, 0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xa6b0a0,
|
||||
0x939f91, 0x5c6a72, 0x829181, 0x8da101, 0xdf69ba, 0x35a77c,
|
||||
0x8da101, 0x35a77c, 0xdfa000, 0xf85552, 0xfdf6e3,
|
||||
0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xe6e2cc, 0xe6e2cc, 0xe0dcc7, 0x939f91,
|
||||
]) : pal([
|
||||
0x1e2326, 0x272e33, 0x2d353b, 0x374145, 0x4f5b58, 0x7a8478,
|
||||
0x859289, 0xd3c6aa, 0xa7c080, 0xa7c080, 0xd699b6, 0x7fbbb3,
|
||||
0xa7c080, 0x83c092, 0xdbbc7f, 0xe67e80, 0x272e33,
|
||||
0x374145, 0x2d353b, 0x4f5b58, 0x1e2326, 0x1e2326, 0x374145, 0x4f5b58,
|
||||
])
|
||||
case .monokai:
|
||||
return light ? pal([
|
||||
0xe6e3d4, 0xfaf8f0, 0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xb8b49e,
|
||||
0x8a8674, 0x2d2a2e, 0x5b5854, 0xe14775, 0x7c5cbf, 0x1c7d9b,
|
||||
0x5b8c2a, 0x1c7d9b, 0xb08900, 0xe14775, 0xfaf8f0,
|
||||
0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xe6e3d4, 0xe6e3d4, 0xd6d2c0, 0x8a8674,
|
||||
]) : pal([
|
||||
0x1d1e19, 0x272822, 0x1e1f1a, 0x3e3d32, 0x49483e, 0x75715e,
|
||||
0x75715e, 0xf8f8f2, 0xa59f85, 0xf92672, 0xae81ff, 0x66d9ef,
|
||||
0xa6e22e, 0xa1efe4, 0xe6db74, 0xf92672, 0x272822,
|
||||
0x3e3d32, 0x2e2f29, 0x49483e, 0x1d1e19, 0x1d1e19, 0x3e3d32, 0x49483e,
|
||||
])
|
||||
case .oneDark:
|
||||
return light ? pal([
|
||||
0xe5e5e6, 0xfafafa, 0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7,
|
||||
0xa0a1a7, 0x383a42, 0x696c77, 0xa626a4, 0x4078f2, 0x0184bc,
|
||||
0x50a14f, 0x0184bc, 0xc18401, 0xe45649, 0xfafafa,
|
||||
0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xe5e5e6, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7,
|
||||
]) : pal([
|
||||
0x21252b, 0x282c34, 0x21252b, 0x3b4048, 0x4b5263, 0x5c6370,
|
||||
0x5c6370, 0xabb2bf, 0x7f848e, 0xc678dd, 0x61afef, 0x56b6c2,
|
||||
0x98c379, 0x56b6c2, 0xe5c07b, 0xe06c75, 0x282c34,
|
||||
0x3b4048, 0x2c313a, 0x4b5263, 0x21252b, 0x21252b, 0x3b4048, 0x4b5263,
|
||||
])
|
||||
}
|
||||
}
|
||||
24
ios/Maturita/Info.plist
Normal file
24
ios/Maturita/Info.plist
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>maturita.c</string>
|
||||
<key>GITCommit</key>
|
||||
<string>$(COMMIT)</string>
|
||||
<key>UPDATE_REPO</key>
|
||||
<string>pepaskopec-blip/maturita.c</string>
|
||||
<key>UPDATE_BRANCH</key>
|
||||
<string>builds</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
154
ios/Maturita/MaturitaApp.swift
Normal file
154
ios/Maturita/MaturitaApp.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
98
ios/Maturita/Route.swift
Normal file
98
ios/Maturita/Route.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
276
ios/Maturita/UI/Components.swift
Normal file
276
ios/Maturita/UI/Components.swift
Normal file
|
|
@ -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<Content: View>: 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<Content: View>: 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)
|
||||
}
|
||||
}
|
||||
608
ios/Maturita/UI/Exercises.swift
Normal file
608
ios/Maturita/UI/Exercises.swift
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ExerciseScaffold<Content: View>: 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..<item.strs("words").count).shuffled()
|
||||
})
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let items = spec.arr("items")
|
||||
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 { i in
|
||||
let words = items[i].strs("words")
|
||||
return placed[i].count == words.count && placed[i].map { words[$0] } == words
|
||||
}
|
||||
if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
|
||||
else { fb = (vm.tr("feedback_sentences"), "err") }
|
||||
revealed = true
|
||||
}, feedback: fb.0, kind: fb.1) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt, palette: p) }
|
||||
FlowLayout {
|
||||
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) }
|
||||
ForEach(placed[i], id: \.self) { idx in
|
||||
if idx < items[i].strs("words").count {
|
||||
WordChip(text: items[i].strs("words")[idx], palette: p) {
|
||||
placed[i].removeAll { $0 == idx }
|
||||
pools[i].append(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.padding(.bottom, 6)
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(pools[i], id: \.self) { idx in
|
||||
if idx < items[i].strs("words").count {
|
||||
WordChip(text: items[i].strs("words")[idx], palette: p) {
|
||||
pools[i].removeAll { $0 == idx }
|
||||
placed[i].append(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if revealed, i < meanings.count {
|
||||
Meaning(text: vm.tr(meanings[i]), palette: p, visible: true)
|
||||
}
|
||||
Spacer().frame(height: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TypedEx: 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 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..<Swift.min($0 + size, count)]) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct VocabEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> 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)
|
||||
}
|
||||
}
|
||||
857
ios/Maturita/UI/ExercisesMore.swift
Normal file
857
ios/Maturita/UI/ExercisesMore.swift
Normal file
|
|
@ -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<Body: View>: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
304
ios/Maturita/UI/Icons.swift
Normal file
304
ios/Maturita/UI/Icons.swift
Normal file
|
|
@ -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..<teeth {
|
||||
let a = -CGFloat.pi / 2 + 2 * CGFloat.pi * CGFloat(i) / CGFloat(teeth)
|
||||
let a0 = a - toothHalf * 0.55
|
||||
let a1 = a - toothHalf * 0.28
|
||||
let a2 = a + toothHalf * 0.28
|
||||
let a3 = a + toothHalf * 0.55
|
||||
let p0 = CGPoint(x: cx + cos(a0) * rInner, y: cy + sin(a0) * rInner)
|
||||
if i == 0 { gear.move(to: p0) } else { gear.addLine(to: p0) }
|
||||
gear.addLine(to: CGPoint(x: cx + cos(a1) * rOuter, y: cy + sin(a1) * rOuter))
|
||||
gear.addLine(to: CGPoint(x: cx + cos(a2) * rOuter, y: cy + sin(a2) * rOuter))
|
||||
gear.addLine(to: CGPoint(x: cx + cos(a3) * rInner, y: cy + sin(a3) * rInner))
|
||||
}
|
||||
gear.closeSubpath()
|
||||
gear.addEllipse(in: CGRect(x: cx - rHole, y: cy - rHole, width: rHole * 2, height: rHole * 2))
|
||||
ctx.fill(gear, with: .color(color), style: FillStyle(eoFill: true))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct LockIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 18
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let body = CGRect(x: sz.width * 0.28, y: sz.height * 0.46, width: sz.width * 0.44, height: sz.height * 0.36)
|
||||
ctx.fill(Path(roundedRect: body, cornerRadius: min(sz.width, sz.height) * 0.08), with: .color(color))
|
||||
var arc = Path()
|
||||
arc.addArc(
|
||||
center: CGPoint(x: sz.width * 0.5, y: sz.height * 0.38),
|
||||
radius: sz.width * 0.18,
|
||||
startAngle: .degrees(200),
|
||||
endAngle: .degrees(340),
|
||||
clockwise: false
|
||||
)
|
||||
ctx.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.1, lineCap: .round))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckIcon: 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.18, y: sz.height * 0.52))
|
||||
p.addLine(to: CGPoint(x: sz.width * 0.4, y: sz.height * 0.74))
|
||||
p.addLine(to: CGPoint(x: sz.width * 0.84, y: sz.height * 0.26))
|
||||
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 WifiIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 28
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let c = CGPoint(x: sz.width / 2, y: sz.height * 0.72)
|
||||
ctx.fill(Path(ellipseIn: CGRect(x: c.x - sz.width * 0.06, y: c.y - sz.width * 0.06, width: sz.width * 0.12, height: sz.width * 0.12)), with: .color(color))
|
||||
for i in 1...3 {
|
||||
let r = min(sz.width, sz.height) * (0.16 + CGFloat(i) * 0.16)
|
||||
var p = Path()
|
||||
p.addArc(center: c, radius: r, startAngle: .degrees(210), endAngle: .degrees(330), clockwise: false)
|
||||
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChipIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 28
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let m = min(sz.width, sz.height) * 0.22
|
||||
ctx.stroke(
|
||||
Path(roundedRect: CGRect(x: m, y: m, width: sz.width - 2 * m, height: sz.height - 2 * m), cornerRadius: 6),
|
||||
with: .color(color),
|
||||
lineWidth: min(sz.width, sz.height) * 0.08
|
||||
)
|
||||
let pin = min(sz.width, sz.height) * 0.08
|
||||
for i in 0..<3 {
|
||||
let t = m + (sz.height - 2 * m) * CGFloat(i + 1) / 4
|
||||
var l = Path(); l.move(to: CGPoint(x: m - pin, y: t)); l.addLine(to: CGPoint(x: m, y: t))
|
||||
var r = Path(); r.move(to: CGPoint(x: sz.width - m, y: t)); r.addLine(to: CGPoint(x: sz.width - m + pin, y: t))
|
||||
ctx.stroke(l, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
|
||||
ctx.stroke(r, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct GermanFlag: View {
|
||||
var size: CGFloat = 48
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let band = sz.height / 3
|
||||
ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
|
||||
ctx.fill(Path(CGRect(x: 0, y: 0, width: sz.width, height: band)), with: .color(Color(red: 0.18, green: 0.18, blue: 0.19)))
|
||||
ctx.fill(Path(CGRect(x: 0, y: band, width: sz.width, height: band)), with: .color(Color(red: 0.72, green: 0.27, blue: 0.24)))
|
||||
ctx.fill(Path(CGRect(x: 0, y: 2 * band, width: sz.width, height: band)), with: .color(Color(red: 0.84, green: 0.70, blue: 0.32)))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct CzechFlag: View {
|
||||
var size: CGFloat = 48
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
|
||||
ctx.fill(Path(CGRect(origin: .zero, size: sz)), with: .color(Color(white: 0.98)))
|
||||
ctx.fill(Path(CGRect(x: 0, y: sz.height / 2, width: sz.width, height: sz.height / 2)), with: .color(Color(red: 0.83, green: 0.16, blue: 0.18)))
|
||||
var tri = Path()
|
||||
tri.move(to: .zero)
|
||||
tri.addLine(to: CGPoint(x: sz.width * 0.55, y: sz.height / 2))
|
||||
tri.addLine(to: CGPoint(x: 0, y: sz.height))
|
||||
tri.closeSubpath()
|
||||
ctx.fill(tri, with: .color(Color(red: 0.07, green: 0.24, blue: 0.55)))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
ctx.stroke(
|
||||
Path(roundedRect: CGRect(x: sz.width * 0.22, y: sz.height * 0.18, width: sz.width * 0.56, height: sz.height * 0.64), cornerRadius: 4),
|
||||
with: .color(color),
|
||||
lineWidth: min(sz.width, sz.height) * 0.08
|
||||
)
|
||||
var spine = Path()
|
||||
spine.move(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.18))
|
||||
spine.addLine(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.82))
|
||||
ctx.stroke(spine, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct QuizIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let r = min(sz.width, sz.height) * 0.36
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
|
||||
let d = min(sz.width, sz.height) * 0.12
|
||||
ctx.fill(Path(ellipseIn: CGRect(x: sz.width / 2 - d / 2, y: sz.height * 0.68 - d / 2, width: d, height: d)), with: .color(color))
|
||||
var stem = Path()
|
||||
stem.move(to: CGPoint(x: sz.width / 2, y: sz.height * 0.32))
|
||||
stem.addLine(to: CGPoint(x: sz.width / 2, y: sz.height * 0.52))
|
||||
ctx.stroke(stem, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct OrderIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
for i in 0..<3 {
|
||||
let y = sz.height * (0.28 + CGFloat(i) * 0.22)
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: sz.width * 0.22, y: y))
|
||||
p.addLine(to: CGPoint(x: sz.width * 0.78, y: y))
|
||||
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobeIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let r = min(sz.width, sz.height) * 0.34
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width * 0.32, y: sz.height * 0.18, width: sz.width * 0.36, height: sz.height * 0.64)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct PeopleIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let r = min(sz.width, sz.height) * 0.12
|
||||
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.35 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
|
||||
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.65 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
|
||||
var a = Path(); a.addArc(center: CGPoint(x: sz.width * 0.35, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
|
||||
var b = Path(); b.addArc(center: CGPoint(x: sz.width * 0.65, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
|
||||
ctx.stroke(a, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
ctx.stroke(b, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct BulbIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let r = min(sz.width, sz.height) * 0.22
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height * 0.4 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
|
||||
ctx.fill(Path(roundedRect: CGRect(x: sz.width * 0.4, y: sz.height * 0.58, width: sz.width * 0.2, height: sz.height * 0.2), cornerRadius: 3), with: .color(color))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func noteIcon(_ name: String, _ color: Color) -> 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)
|
||||
}
|
||||
}
|
||||
63
ios/Maturita/UI/PathMap.swift
Normal file
63
ios/Maturita/UI/PathMap.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
698
ios/Maturita/UI/Screens.swift
Normal file
698
ios/Maturita/UI/Screens.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
ios/Maturita/Update/Updater.swift
Normal file
113
ios/Maturita/Update/Updater.swift
Normal file
|
|
@ -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<String, Error> = .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()
|
||||
}
|
||||
}
|
||||
38
scripts/bundle-ios.sh
Executable file
38
scripts/bundle-ios.sh
Executable file
|
|
@ -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"
|
||||
|
|
@ -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'])}")
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue