diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 56f5d3d..e1d5293 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -194,9 +194,29 @@ jobs: name: maturita-android path: dist-android/maturita-android.apk + ios: + name: iOS IPA + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Extract content and build IPA + env: + COMMIT: ${{ github.sha }} + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + run: | + chmod +x scripts/bundle-ios.sh + ./scripts/bundle-ios.sh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: maturita-ios + path: dist-ios/maturita-ios.ipa + publish: name: Publish to builds branch - needs: [linux, windows, macos, android] + needs: [linux, windows, macos, android, ios] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index fd0137f..5437850 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,10 @@ android/*.iml android/.cxx/ android/.local-sdk/ android/app/src/main/assets/content.json + +# iOS +ios/DerivedData/ +ios/build/ +ios/Payload/ +ios/Maturita/content.json +ios/**/xcuserdata/ diff --git a/Makefile b/Makefile index b911362..7f6505f 100644 --- a/Makefile +++ b/Makefile @@ -119,4 +119,8 @@ android: python3 scripts/extract-android-content.py cd android && ./gradlew :app:assembleRelease -.PHONY: all clean run bundle installer-zips android +ios: + chmod +x scripts/bundle-ios.sh + ./scripts/bundle-ios.sh + +.PHONY: all clean run bundle installer-zips android ios diff --git a/README.md b/README.md index b9acd25..a45ce90 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ větve. | Windows (x64) | [maturita-installer-windows.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-windows.zip) | Rozbalit a dvojklik; u SmartScreenu **Další informace → Přesto spustit** | | Linux (x86_64) | [maturita-installer-linux.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-linux.zip) | Rozbalit a dvojklik na instalátor | | Android (8+) | [maturita-installer-android.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-android.zip) | Rozbalit, otevřít **Stahnout maturita.C.html** a nainstalovat APK (neznámé zdroje) | +| iOS (16+) | [maturita-installer-ios.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-ios.zip) | Rozbalit, otevřít **Jak nainstalovat maturita.C.html**. Nejjednodušší: Xcode na Macu, nebo IPA přes AltStore | Kam se to nainstaluje: `/Applications` nebo `~/Applications` (macOS), `%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux). @@ -51,9 +52,10 @@ spolehlivější instalátor výše, nebo stažení přímo z té stránky větv - [maturita-windows-x64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-windows-x64.zip) - [maturita-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-linux-x86_64.AppImage) - [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-android.apk) +- [maturita-ios.ipa](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-ios.ipa) Postup a nastavení se ukládají vedle `.app` / složky / AppImage do `progress/`. -Na Androidu do úložiště aplikace (stejné statistiky, témata a jazyk). +Na Androidu a iOS do úložiště aplikace (stejné statistiky, témata a jazyk). Intel Mac, jiná architektura nebo úpravy kódu → [sestavení ze zdroje](#sestavení-ze-zdroje). @@ -138,10 +140,11 @@ make bundle # dist/maturita.exe jde spustit i z Průzkumník ``` src/ C zdroje a maturita.h android/ Jetpack Compose přehrávač (stejný obsah) +ios/ SwiftUI přehrávač (stejný obsah) data/ style.css, share/, podpisy, Windows .rc assets/ zdrojová ikona a screenshoty installers/ instalátory, které stáhnou aktuální build -scripts/ balení AppImage, .app a extract-android-content.py +scripts/ balení AppImage, .app, IPA a extract-android-content.py .github/workflows/ CI: push na main → větev builds progress/ vzniká za běhu (cvičení, nastavení) ``` @@ -157,6 +160,15 @@ cd android nebo z kořene `make android`. +iOS ze zdroje (Xcode 16, iOS 16+): + +```bash +python3 scripts/extract-android-content.py +open ios/Maturita.xcodeproj +# Signing: vlastní Team, Run na iPhone +# nebo: make ios # unsigned IPA v dist-ios/maturita-ios.ipa +``` + Licence: [GPL-3.0](LICENSE). --- @@ -186,6 +198,7 @@ branch-named file. | Windows (x64) | [maturita-installer-windows.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-windows.zip) | Unzip and double-click; if SmartScreen appears, **More info → Run anyway** | | Linux (x86_64) | [maturita-installer-linux.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-linux.zip) | Unzip and double-click the installer | | Android (8+) | [maturita-installer-android.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-android.zip) | Unzip, open **Stahnout maturita.C.html**, then install the APK (unknown sources) | +| iOS (16+) | [maturita-installer-ios.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-ios.zip) | Unzip, open **Jak nainstalovat maturita.C.html**. Easiest: Xcode on a Mac, or the IPA via AltStore | Install locations: `/Applications` or `~/Applications` (macOS), `%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux). @@ -206,9 +219,10 @@ above, or download from that branch page: - [maturita-windows-x64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-windows-x64.zip) - [maturita-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-linux-x86_64.AppImage) - [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-android.apk) +- [maturita-ios.ipa](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-ios.ipa) Progress and settings live in `progress/` next to the `.app` / folder / -AppImage. On Android they stay in app storage (same stats, themes, language). +AppImage. On Android and iOS they stay in app storage (same stats, themes, language). Intel Mac, another architecture, or hacking on the code → [build from source](#build-from-source). @@ -294,10 +308,11 @@ make bundle # dist/maturita.exe can be double-clicked ``` src/ C sources and maturita.h android/ Jetpack Compose player (same content) +ios/ SwiftUI player (same content) data/ style.css, share/, signing files, Windows .rc assets/ source icon and screenshots installers/ fetch-the-latest installers -scripts/ AppImage / .app bundlers and extract-android-content.py +scripts/ AppImage / .app / IPA bundlers and extract-android-content.py .github/workflows/ CI: push to main → builds branch progress/ created at runtime (exercises, settings) ``` @@ -313,4 +328,13 @@ cd android or `make android` from the repo root. +iOS from source (Xcode 16, iOS 16+): + +```bash +python3 scripts/extract-android-content.py +open ios/Maturita.xcodeproj +# Signing: your Team, Run on iPhone +# or: make ios # unsigned IPA at dist-ios/maturita-ios.ipa +``` + License: [GPL-3.0](LICENSE). diff --git a/installers/maturita-installer-ios.zip b/installers/maturita-installer-ios.zip new file mode 100644 index 0000000..99dd15c Binary files /dev/null and b/installers/maturita-installer-ios.zip differ diff --git a/installers/maturita-ios.html b/installers/maturita-ios.html new file mode 100644 index 0000000..fcc5eac --- /dev/null +++ b/installers/maturita-ios.html @@ -0,0 +1,72 @@ + + + + + + maturita.C pro iOS + + + +

maturita.C pro iPhone

+

+ Apple nepovolí instalaci jako Android APK. Nejjednodušší cesta je Mac + s Xcode a vlastní iPhone (bezplatný Apple účet stačí na 7 dní). +

+
    +
  1. Naklonuj maturita.c
  2. +
  3. Otevři ios/Maturita.xcodeproj
  4. +
  5. V Signing vyber svůj Team
  6. +
  7. Připoj iPhone a stiskni Run
  8. +
+

+ IPA z větve builds jde nahrát i přes AltStore nebo Sideloadly + (unsigned balíček, potřebuješ nástroj na sideload). +

+

+ + Stáhnout IPA + +

+

+ Když odkaz hlásí 404, otevři + větev builds + a stáhni maturita-ios.ipa odtud. +

+ + + diff --git a/installers/pack-installer-zips.sh b/installers/pack-installer-zips.sh index fa54bed..8b870c4 100755 --- a/installers/pack-installer-zips.sh +++ b/installers/pack-installer-zips.sh @@ -32,3 +32,8 @@ cp maturita-android.html "$tmp/Stahnout maturita.C.html" rm -f "$here/maturita-installer-android.zip" (cd "$tmp" && zip -q -FS "$here/maturita-installer-android.zip" \ "Stahnout maturita.C.html") + +cp maturita-ios.html "$tmp/Jak nainstalovat maturita.C.html" +rm -f "$here/maturita-installer-ios.zip" +(cd "$tmp" && zip -q -FS "$here/maturita-installer-ios.zip" \ + "Jak nainstalovat maturita.C.html") diff --git a/ios/Maturita.xcodeproj/project.pbxproj b/ios/Maturita.xcodeproj/project.pbxproj new file mode 100644 index 0000000..85bfdc9 --- /dev/null +++ b/ios/Maturita.xcodeproj/project.pbxproj @@ -0,0 +1,370 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A101000000000000000001 /* MaturitaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000001 /* MaturitaApp.swift */; }; + A101000000000000000002 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000002 /* AppModel.swift */; }; + A101000000000000000003 /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000003 /* Route.swift */; }; + A101000000000000000004 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000004 /* JSON.swift */; }; + A101000000000000000005 /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000005 /* Content.swift */; }; + A101000000000000000006 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000006 /* Theme.swift */; }; + A101000000000000000007 /* Answers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000007 /* Answers.swift */; }; + A101000000000000000008 /* ProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000008 /* ProgressStore.swift */; }; + A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; }; + A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; }; + A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.swift */; }; + A101000000000000000012 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000012 /* PathMap.swift */; }; + A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; }; + A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; }; + A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.swift */; }; + A101000000000000000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A201000000000000000016 /* Assets.xcassets */; }; + A101000000000000000017 /* content.json in Resources */ = {isa = PBXBuildFile; fileRef = A201000000000000000017 /* content.json */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A201000000000000000001 /* MaturitaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaturitaApp.swift; sourceTree = ""; }; + A201000000000000000002 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + A201000000000000000003 /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route.swift; sourceTree = ""; }; + A201000000000000000004 /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; + A201000000000000000005 /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; }; + A201000000000000000006 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + A201000000000000000007 /* Answers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Answers.swift; sourceTree = ""; }; + A201000000000000000008 /* ProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressStore.swift; sourceTree = ""; }; + A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; }; + A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; + A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; }; + A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = ""; }; + A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = ""; }; + A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = ""; }; + A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = ""; }; + A201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A201000000000000000017 /* content.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = content.json; sourceTree = ""; }; + A201000000000000000018 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A301000000000000000001 /* Maturita.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Maturita.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A401000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A501000000000000000001 = { + isa = PBXGroup; + children = ( + A501000000000000000002 /* Maturita */, + A501000000000000000006 /* Products */, + ); + sourceTree = ""; + }; + A501000000000000000002 /* Maturita */ = { + isa = PBXGroup; + children = ( + A201000000000000000001 /* MaturitaApp.swift */, + A201000000000000000002 /* AppModel.swift */, + A201000000000000000003 /* Route.swift */, + A501000000000000000003 /* Data */, + A501000000000000000004 /* Update */, + A501000000000000000005 /* UI */, + A201000000000000000016 /* Assets.xcassets */, + A201000000000000000017 /* content.json */, + A201000000000000000018 /* Info.plist */, + ); + path = Maturita; + sourceTree = ""; + }; + A501000000000000000003 /* Data */ = { + isa = PBXGroup; + children = ( + A201000000000000000004 /* JSON.swift */, + A201000000000000000005 /* Content.swift */, + A201000000000000000006 /* Theme.swift */, + A201000000000000000007 /* Answers.swift */, + A201000000000000000008 /* ProgressStore.swift */, + ); + path = Data; + sourceTree = ""; + }; + A501000000000000000004 /* Update */ = { + isa = PBXGroup; + children = ( + A201000000000000000009 /* Updater.swift */, + ); + path = Update; + sourceTree = ""; + }; + A501000000000000000005 /* UI */ = { + isa = PBXGroup; + children = ( + A201000000000000000010 /* Components.swift */, + A201000000000000000011 /* Icons.swift */, + A201000000000000000012 /* PathMap.swift */, + A201000000000000000013 /* Screens.swift */, + A201000000000000000014 /* Exercises.swift */, + A201000000000000000015 /* ExercisesMore.swift */, + ); + path = UI; + sourceTree = ""; + }; + A501000000000000000006 /* Products */ = { + isa = PBXGroup; + children = ( + A301000000000000000001 /* Maturita.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A601000000000000000001 /* Maturita */ = { + isa = PBXNativeTarget; + buildConfigurationList = A801000000000000000002 /* Build configuration list for PBXNativeTarget "Maturita" */; + buildPhases = ( + A701000000000000000004 /* Extract content */, + A701000000000000000001 /* Sources */, + A401000000000000000001 /* Frameworks */, + A701000000000000000002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Maturita; + productName = Maturita; + productReference = A301000000000000000001 /* Maturita.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A001000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + A601000000000000000001 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = A801000000000000000001 /* Build configuration list for PBXProject "Maturita" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = cs; + hasScannedForEncodings = 0; + knownRegions = ( + en, + cs, + Base, + ); + mainGroup = A501000000000000000001; + productRefGroup = A501000000000000000006 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A601000000000000000001 /* Maturita */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A701000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A101000000000000000016 /* Assets.xcassets in Resources */, + A101000000000000000017 /* content.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + A701000000000000000004 /* Extract content */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Extract content"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Maturita/content.json", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "python3 \"${SRCROOT}/../scripts/extract-android-content.py\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A701000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A101000000000000000001 /* MaturitaApp.swift in Sources */, + A101000000000000000002 /* AppModel.swift in Sources */, + A101000000000000000003 /* Route.swift in Sources */, + A101000000000000000004 /* JSON.swift in Sources */, + A101000000000000000005 /* Content.swift in Sources */, + A101000000000000000006 /* Theme.swift in Sources */, + A101000000000000000007 /* Answers.swift in Sources */, + A101000000000000000008 /* ProgressStore.swift in Sources */, + A101000000000000000009 /* Updater.swift in Sources */, + A101000000000000000010 /* Components.swift in Sources */, + A101000000000000000011 /* Icons.swift in Sources */, + A101000000000000000012 /* PathMap.swift in Sources */, + A101000000000000000013 /* Screens.swift in Sources */, + A101000000000000000014 /* Exercises.swift in Sources */, + A101000000000000000015 /* ExercisesMore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A901000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A901000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A901000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + COMMIT = dev; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Maturita/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "maturita.c"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = ios-1; + PRODUCT_BUNDLE_IDENTIFIER = org.maturita.maturita; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A901000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + COMMIT = dev; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Maturita/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "maturita.c"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = ios-1; + PRODUCT_BUNDLE_IDENTIFIER = org.maturita.maturita; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A801000000000000000001 /* Build configuration list for PBXProject "Maturita" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A901000000000000000001 /* Debug */, + A901000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A801000000000000000002 /* Build configuration list for PBXNativeTarget "Maturita" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A901000000000000000003 /* Debug */, + A901000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A001000000000000000001 /* Project object */; +} diff --git a/ios/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Maturita.xcodeproj/xcshareddata/xcschemes/Maturita.xcscheme b/ios/Maturita.xcodeproj/xcshareddata/xcschemes/Maturita.xcscheme new file mode 100644 index 0000000..0ea63f4 --- /dev/null +++ b/ios/Maturita.xcodeproj/xcshareddata/xcschemes/Maturita.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Maturita/AppModel.swift b/ios/Maturita/AppModel.swift new file mode 100644 index 0000000..46af406 --- /dev/null +++ b/ios/Maturita/AppModel.swift @@ -0,0 +1,113 @@ +import Foundation +import SwiftUI + +final class AppModel: ObservableObject { + let content = Content.load() + let progress = ProgressStore() + let updater = Updater() + + @Published var themeId: ThemeId + @Published var mode: ColorMode + @Published var lang: UiLang + @Published var settingsOpen = false + @Published var searchOpen = false + @Published var searchQuery = "" + @Published var tick = 0 + @Published var update = UpdateState() + @Published var stack: [Route] = [.welcome] + + init() { + themeId = progress.themeId + mode = progress.mode + lang = progress.lang + } + + var route: Route { stack.last ?? .welcome } + var canGoBack: Bool { stack.count > 1 } + var palette: Palette { themePalette(themeId, mode) } + + func tr(_ key: String?) -> String { content.tr(key, lang: lang) } + + func fmt(_ key: String, _ args: CVarArg...) -> String { + content.fmt(key, lang: lang, args) + } + + func go(_ r: Route) { + if stack.last == r { return } + stack.append(r) + searchOpen = false + } + + func goPage(_ page: String?) { + guard let page, let r = routeFromPage(page) else { return } + go(r) + } + + func back() { + if stack.count > 1 { stack.removeLast() } + } + + func setTheme(_ id: ThemeId) { + themeId = id + progress.themeId = id + } + + func applyMode(_ m: ColorMode) { + mode = m + progress.mode = m + } + + func applyLang(_ l: UiLang) { + lang = l + progress.lang = l + } + + func refresh() { tick += 1 } + + func markGerman(_ unit: Int, _ ex: Int) { + progress.markGerman(unit, ex) + refresh() + } + + func markVocab(_ unit: Int) { + progress.markVocab(unit) + refresh() + } + + func markNet(_ id: Int) { + progress.markNet(id) + refresh() + } + + func markHw(_ id: Int) { + progress.markHw(id) + refresh() + } + + func markMluv(_ n: Int) { + progress.markMluv(n) + refresh() + } + + func markBookQuiz(_ id: String) { + progress.markBookQuiz(id) + refresh() + } + + func markBookPlot(_ id: String) { + progress.markBookPlot(id) + refresh() + } + + func checkUpdate(_ interactive: Bool) { + updater.check(interactive: interactive) { [weak self] state in + self?.update = state + } + } + + func installUpdate() { + updater.install { [weak self] state in + self?.update = state + } + } +} diff --git a/ios/Maturita/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Maturita/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/Maturita/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Maturita/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/Maturita/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..80afb9d Binary files /dev/null and b/ios/Maturita/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/Maturita/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Maturita/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/ios/Maturita/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Maturita/Assets.xcassets/Contents.json b/ios/Maturita/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Maturita/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Maturita/Data/Answers.swift b/ios/Maturita/Data/Answers.swift new file mode 100644 index 0000000..b94dd7d --- /dev/null +++ b/ios/Maturita/Data/Answers.swift @@ -0,0 +1,60 @@ +import Foundation + +func normalizeAnswer(_ input: String?) -> String { + var pre = "" + for ch in (input ?? "").lowercased() { + switch ch { + case "ä": pre += "ae" + case "ö": pre += "oe" + case "ü": pre += "ue" + case "ß": pre += "ss" + default: pre.append(ch) + } + } + let decomp = pre.decomposedStringWithCanonicalMapping + var out = "" + var prevSpace = false + for scalar in decomp.unicodeScalars { + let v = scalar.value + if (0x0300...0x036F).contains(v) || (0x1AB0...0x1AFF).contains(v) || + (0x1DC0...0x1DFF).contains(v) || (0x20D0...0x20FF).contains(v) || + (0xFE20...0xFE2F).contains(v) { + continue + } + if CharacterSet.whitespacesAndNewlines.contains(scalar) { + if !out.isEmpty && !prevSpace { out.append(" ") } + prevSpace = true + continue + } + if !CharacterSet.alphanumerics.contains(scalar) { continue } + prevSpace = false + out.unicodeScalars.append(scalar) + } + return out.trimmingCharacters(in: .whitespaces) +} + +func answerAccepts(_ selNorm: String, _ ans: String?) -> Bool { + guard let ans, !ans.isEmpty else { return false } + return ans.split(separator: "|").contains { normalizeAnswer(String($0)) == selNorm } +} + +func answerIncomplete(_ selNorm: String, _ ans: String?) -> Bool { + guard !selNorm.isEmpty, let ans, !ans.isEmpty else { return false } + for part in ans.split(separator: "|") { + let a = normalizeAnswer(String(part)) + if a.hasPrefix(selNorm + " ") && a.count > selNorm.count + 1 { return true } + } + return false +} + +func netTxtEq(_ a: String?, _ b: String?) -> Bool { + guard let a, let b else { return a == b } + let x = a.filter { !$0.isWhitespace }.lowercased() + let y = b.filter { !$0.isWhitespace }.lowercased() + return x == y +} + +func hasUmlaut(_ s: String?) -> Bool { + guard let s else { return false } + return s.contains(where: { "äöüÄÖÜß".contains($0) }) +} diff --git a/ios/Maturita/Data/Content.swift b/ios/Maturita/Data/Content.swift new file mode 100644 index 0000000..e556d5b --- /dev/null +++ b/ios/Maturita/Data/Content.swift @@ -0,0 +1,95 @@ +import Foundation + +enum AppConfig { + static var commit: String { + let raw = (Bundle.main.object(forInfoDictionaryKey: "GITCommit") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty || raw.hasPrefix("$(") ? "dev" : raw + } + + static var versionName: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ios-1" + } + + static var updateRepo: String { + (Bundle.main.object(forInfoDictionaryKey: "UPDATE_REPO") as? String) + ?? "pepaskopec-blip/maturita.c" + } + + static var updateBranch: String { + (Bundle.main.object(forInfoDictionaryKey: "UPDATE_BRANCH") as? String) ?? "builds" + } +} + +final class Content { + let raw: J + let i18n: [String: (String?, String?)] + let subjects: [J] + let german: [J] + let net: J + let netLessons: [J] + let netTasks: [J] + let netAnswers: [[J]] + let hw: [J] + let mluvnice: [J] + let books: [J] + + init(_ root: [String: Any]) { + raw = J(root) + let obj = root["i18n"] as? [String: Any] ?? [:] + var map: [String: (String?, String?)] = [:] + for (key, value) in obj { + let e = J(value) + let cs = e.strOrNull("cs") + let en = e.strOrNull("en") + map[key] = (cs, en) + } + i18n = map + subjects = raw.arr("subjects") + german = raw.arr("german") + net = raw.obj("net") ?? J([:]) + netLessons = net.arr("lessons") + netTasks = net.arr("tasks") + if let a = net.o["answers"] as? [Any] { + netAnswers = a.map { block in + guard let rows = block as? [Any] else { return [] } + return rows.compactMap { item in + item is [String: Any] ? J(item) : nil + } + } + } else { + netAnswers = [] + } + hw = raw.arr("hw") + mluvnice = raw.arr("mluvnice") + books = raw.arr("books") + } + + func germanUnit(_ id: Int) -> J? { german.first { $0.int("id") == id } } + func netLesson(_ id: Int) -> J? { netLessons.first { $0.int("id") == id } } + func hwLesson(_ id: Int) -> J? { hw.first { $0.int("id") == id } } + func mluv(_ n: Int) -> J? { mluvnice.first { $0.int("id") == n } } + func book(_ id: String) -> J? { books.first { $0.str("id") == id } } + + func tr(_ key: String?, lang: UiLang) -> String { + guard let key, !key.isEmpty else { return "" } + let hit = i18n[key] + if lang == .en { return hit?.1 ?? key } + return hit?.0 ?? key + } + + func fmt(_ key: String, lang: UiLang, _ args: [CVarArg]) -> String { + let template = tr(key, lang: lang).replacingOccurrences(of: "%s", with: "%@") + return String(format: template, arguments: args) + } + + static func load() -> Content { + guard let url = Bundle.main.url(forResource: "content", withExtension: "json"), + let data = try? Data(contentsOf: url), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return Content([:]) + } + return Content(obj) + } +} diff --git a/ios/Maturita/Data/JSON.swift b/ios/Maturita/Data/JSON.swift new file mode 100644 index 0000000..137e769 --- /dev/null +++ b/ios/Maturita/Data/JSON.swift @@ -0,0 +1,102 @@ +import Foundation + +struct J { + let o: [String: Any] + + init(_ obj: Any?) { + o = obj as? [String: Any] ?? [:] + } + + func has(_ key: String) -> Bool { + guard let value = o[key], !(value is NSNull) else { return false } + return true + } + + func str(_ key: String, _ fallback: String = "") -> String { + guard has(key) else { return fallback } + if let s = o[key] as? String { return s } + if let n = o[key] as? NSNumber { return n.stringValue } + return fallback + } + + func strOrNull(_ key: String) -> String? { + guard has(key) else { return nil } + if let s = o[key] as? String, !s.isEmpty, s != "null" { return s } + return nil + } + + func bool(_ key: String, _ fallback: Bool = false) -> Bool { + if let b = o[key] as? Bool { return b } + if let n = o[key] as? NSNumber { return n.boolValue } + return fallback + } + + func int(_ key: String, _ fallback: Int = 0) -> Int { + if let n = o[key] as? Int { return n } + if let n = o[key] as? NSNumber { return n.intValue } + if let s = o[key] as? String, let n = Int(s) { return n } + return fallback + } + + func obj(_ key: String) -> J? { + guard let value = o[key], !(value is NSNull) else { return nil } + if value is [String: Any] { return J(value) } + return nil + } + + func exercise(_ n: Int) -> J? { + guard let ex = o["exercises"] as? [String: Any] else { return nil } + return J(ex["\(n)"]) + } + + func arr(_ key: String) -> [J] { + guard let a = o[key] as? [Any] else { return [] } + return a.compactMap { item in + item is [String: Any] ? J(item) : nil + } + } + + func strs(_ key: String) -> [String] { + guard let a = o[key] as? [Any] else { return [] } + return a.compactMap { item in + if item is NSNull { return nil } + if let s = item as? String, !s.isEmpty { return s } + return nil + } + } + + func strsOrEmpty(_ key: String) -> [String?] { + guard let a = o[key] as? [Any] else { return [] } + return a.map { item in + if item is NSNull { return nil } + return item as? String + } + } + + func intRows(_ key: String) -> [[String]] { + guard let a = o[key] as? [Any] else { return [] } + return a.map { row in + guard let cells = row as? [Any] else { return [] } + return cells.map { cell in + if let s = cell as? String { return s } + if let n = cell as? NSNumber { return n.stringValue } + return "" + } + } + } + + func rawArray(_ key: String) -> [Any] { + o[key] as? [Any] ?? [] + } + + func answerList() -> [String] { + if let a = o["answers"] as? [Any] { + return a.compactMap { item in + if item is NSNull { return nil } + if let s = item as? String, !s.isEmpty { return s } + return nil + } + } + return [strOrNull("answers")].compactMap { $0 } + } +} diff --git a/ios/Maturita/Data/ProgressStore.swift b/ios/Maturita/Data/ProgressStore.swift new file mode 100644 index 0000000..b8c6432 --- /dev/null +++ b/ios/Maturita/Data/ProgressStore.swift @@ -0,0 +1,81 @@ +import Foundation + +final class ProgressStore { + private let defaults = UserDefaults.standard + + var themeId: ThemeId { + get { ThemeId(rawValue: defaults.integer(forKey: "theme")) ?? .catppuccin } + set { defaults.set(newValue.rawValue, forKey: "theme") } + } + + var mode: ColorMode { + get { defaults.string(forKey: "mode") == "dark" ? .dark : .light } + set { defaults.set(newValue == .light ? "light" : "dark", forKey: "mode") } + } + + var lang: UiLang { + get { defaults.string(forKey: "lang") == "en" ? .en : .cs } + set { defaults.set(newValue == .en ? "en" : "cs", forKey: "lang") } + } + + func germanDone(_ unit: Int, _ ex: Int) -> Bool { defaults.bool(forKey: "g.\(unit).\(ex)") } + func markGerman(_ unit: Int, _ ex: Int) { defaults.set(true, forKey: "g.\(unit).\(ex)") } + func vocabDone(_ unit: Int) -> Bool { defaults.bool(forKey: "g.\(unit).vocab") } + func markVocab(_ unit: Int) { defaults.set(true, forKey: "g.\(unit).vocab") } + + func netDone(_ id: Int) -> Bool { defaults.bool(forKey: "net.\(id)") } + func markNet(_ id: Int) { defaults.set(true, forKey: "net.\(id)") } + + func hwDone(_ id: Int) -> Bool { defaults.bool(forKey: "hw.\(id)") } + func markHw(_ id: Int) { defaults.set(true, forKey: "hw.\(id)") } + + func mluvDone(_ n: Int) -> Bool { defaults.bool(forKey: "mluv.\(n)") } + func markMluv(_ n: Int) { defaults.set(true, forKey: "mluv.\(n)") } + + func bookQuiz(_ id: String) -> Bool { defaults.bool(forKey: "book.\(id).quiz") } + func markBookQuiz(_ id: String) { defaults.set(true, forKey: "book.\(id).quiz") } + func bookPlot(_ id: String) -> Bool { defaults.bool(forKey: "book.\(id).plot") } + func markBookPlot(_ id: String) { defaults.set(true, forKey: "book.\(id).plot") } +} + +struct ProgressSum { + var doneEx = 0 + var totalEx = 0 + var doneUnits = 0 + var openUnits = 0 +} + +func summarize(_ content: Content, _ p: ProgressStore) -> ProgressSum { + var sum = ProgressSum() + for u in content.german where u.bool("unlocked") { + let id = u.int("id") + let names = u.strs("names") + sum.totalEx += names.count + sum.openUnits += 1 + let done = names.indices.filter { p.germanDone(id, $0 + 1) }.count + sum.doneEx += done + if !names.isEmpty && done == names.count { sum.doneUnits += 1 } + } + for l in content.netLessons { + sum.totalEx += 1 + sum.openUnits += 1 + if p.netDone(l.int("id")) { + sum.doneEx += 1 + sum.doneUnits += 1 + } + } + for l in content.hw { + sum.totalEx += 1 + sum.openUnits += 1 + if p.hwDone(l.int("id")) { + sum.doneEx += 1 + sum.doneUnits += 1 + } + } + sum.totalEx += content.mluvnice.count + sum.openUnits += 1 + let md = content.mluvnice.filter { p.mluvDone($0.int("id")) }.count + sum.doneEx += md + if md == content.mluvnice.count && !content.mluvnice.isEmpty { sum.doneUnits += 1 } + return sum +} diff --git a/ios/Maturita/Data/Theme.swift b/ios/Maturita/Data/Theme.swift new file mode 100644 index 0000000..2ff0a59 --- /dev/null +++ b/ios/Maturita/Data/Theme.swift @@ -0,0 +1,173 @@ +import SwiftUI + +enum ThemeId: Int, CaseIterable { + case catppuccin, nord, dracula, rosePine, ocean, gruvbox, solarized, everforest, monokai, oneDark +} + +enum ColorMode { + case dark, light +} + +enum UiLang { + case cs, en +} + +let themeNames = [ + "Catppuccin", "Nord", "Dracula", "Rose Pine", "Ocean", + "Gruvbox", "Solarized", "Everforest", "Monokai", "One Dark", +] + +struct Palette { + let crust, base, mantle, surface0, surface1, surface2: Color + let overlay, text, subtext: Color + let accent, accent2, accent3: Color + let success, success2, warning, error: Color + let onAccent, node, lockedBg, lockedBorder, rail: Color + let finishDark, finishLight, finishStroke: Color +} + +private func hx(_ v: UInt32) -> Color { + Color( + red: Double((v >> 16) & 0xFF) / 255, + green: Double((v >> 8) & 0xFF) / 255, + blue: Double(v & 0xFF) / 255 + ) +} + +private func pal(_ c: [UInt32]) -> Palette { + Palette( + crust: hx(c[0]), base: hx(c[1]), mantle: hx(c[2]), surface0: hx(c[3]), + surface1: hx(c[4]), surface2: hx(c[5]), overlay: hx(c[6]), text: hx(c[7]), + subtext: hx(c[8]), accent: hx(c[9]), accent2: hx(c[10]), accent3: hx(c[11]), + success: hx(c[12]), success2: hx(c[13]), warning: hx(c[14]), error: hx(c[15]), + onAccent: hx(c[16]), node: hx(c[17]), lockedBg: hx(c[18]), + lockedBorder: hx(c[19]), rail: hx(c[20]), finishDark: hx(c[21]), + finishLight: hx(c[22]), finishStroke: hx(c[23]) + ) +} + +func themePalette(_ id: ThemeId, _ mode: ColorMode) -> Palette { + let light = mode == .light + switch id { + case .catppuccin: + return light ? pal([ + 0xf3eee6, 0xfaf7f2, 0xffffff, 0xffffff, 0xf0ebe3, 0xe5dfd4, + 0x8a8378, 0x1a1a1a, 0x5c574f, 0xffcc00, 0x0071e3, 0x2997ff, + 0x1f8a4c, 0x1a7f4a, 0xe6a800, 0xd32f2f, 0x1a1a1a, + 0xffffff, 0xf0ebe3, 0xd5cfc4, 0xe5dfd4, 0xf3eee6, 0xd5cfc4, 0x8a8378, + ]) : pal([ + 0x000000, 0x000000, 0x111111, 0x1d1d1f, 0x2c2c2e, 0x3a3a3c, + 0x86868b, 0xf5f5f7, 0xa1a1a6, 0xffd60a, 0x2997ff, 0x64d2ff, + 0x30d158, 0x64d2ff, 0xffd60a, 0xff453a, 0x1d1d1f, + 0x1d1d1f, 0x111111, 0x3a3a3c, 0x2c2c2e, 0x000000, 0x3a3a3c, 0x86868b, + ]) + case .nord: + return light ? pal([ + 0xd8dee9, 0xeceff4, 0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xb0b8c8, + 0x7b88a1, 0x2e3440, 0x4c566a, 0x5e81ac, 0x81a1c1, 0x5e81ac, + 0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0xeceff4, + 0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xd8dee9, 0xd8dee9, 0xc7ceda, 0x7b88a1, + ]) : pal([ + 0x2e3440, 0x3b4252, 0x2e3440, 0x434c5e, 0x4c566a, 0x616e88, + 0x7b88a1, 0xeceff4, 0xd8dee9, 0x88c0d0, 0x81a1c1, 0x5e81ac, + 0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0x2e3440, + 0x434c5e, 0x3b4252, 0x4c566a, 0x2e3440, 0x2e3440, 0x434c5e, 0x4c566a, + ]) + case .dracula: + return light ? pal([ + 0xd5d7e0, 0xf8f8f2, 0xefefea, 0xe2e2d8, 0xd0d0c4, 0xbfbfb2, + 0x9a9a8c, 0x282a36, 0x44475a, 0x7c3aed, 0xd946a6, 0x0891b2, + 0x16a34a, 0x0e7490, 0xca8a04, 0xdc2626, 0xf8f8f2, + 0xefefea, 0xe2e2d8, 0xd0d0c4, 0xe2e2d8, 0xd5d7e0, 0xd0d0c4, 0x9a9a8c, + ]) : pal([ + 0x191a21, 0x282a36, 0x21222c, 0x44475a, 0x6272a4, 0x707eb0, + 0x6272a4, 0xf8f8f2, 0xbfbfb2, 0xbd93f9, 0xff79c6, 0x8be9fd, + 0x50fa7b, 0x8be9fd, 0xf1fa8c, 0xff5555, 0x282a36, + 0x383a4a, 0x2f3240, 0x44475a, 0x21222c, 0x191a21, 0x383a4a, 0x6272a4, + ]) + case .rosePine: + return light ? pal([ + 0xf2e9e1, 0xfaf4ed, 0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xcecacd, + 0x9893a5, 0x575279, 0x797593, 0x907aa9, 0xd7827e, 0x56949f, + 0x286983, 0x56949f, 0xea9d34, 0xb4637a, 0xfaf4ed, + 0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xf2e9e1, 0xf2e9e1, 0xdfdad9, 0x9893a5, + ]) : pal([ + 0x191724, 0x1f1d2e, 0x1f1d2e, 0x26233a, 0x403d52, 0x524f67, + 0x6e6a86, 0xe0def4, 0x908caa, 0xc4a7e7, 0xebbcba, 0x9ccfd8, + 0x31748f, 0x9ccfd8, 0xf6c177, 0xeb6f92, 0x191724, + 0x26233a, 0x21202e, 0x403d52, 0x191724, 0x191724, 0x26233a, 0x524f67, + ]) + case .ocean: + return light ? pal([ + 0xd6dae8, 0xe1e2ec, 0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0x9aa0b5, + 0x7a8199, 0x2e3446, 0x4a5168, 0x2e7de9, 0x7847bd, 0x007197, + 0x387068, 0x007197, 0x8c6c3e, 0xc64343, 0xe1e2ec, + 0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0xc8c9d6, 0xd6dae8, 0xb4b6c5, 0x7a8199, + ]) : pal([ + 0x16161e, 0x1a1b26, 0x16161e, 0x24283b, 0x414868, 0x565f89, + 0x565f89, 0xc0caf5, 0xa9b1d6, 0x7aa2f7, 0xbb9af7, 0x7dcfff, + 0x9ece6a, 0x73daca, 0xe0af68, 0xf7768e, 0x1a1b26, + 0x24283b, 0x1f2335, 0x3b4261, 0x16161e, 0x16161e, 0x24283b, 0x414868, + ]) + case .gruvbox: + return light ? pal([ + 0xebdbb2, 0xfbf1c7, 0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xbdae93, + 0x928374, 0x3c3836, 0x504945, 0xd65d0e, 0xb16286, 0x076678, + 0x79740e, 0x427b58, 0xb57614, 0x9d0006, 0xfbf1c7, + 0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xebdbb2, 0xebdbb2, 0xd5c4a1, 0x928374, + ]) : pal([ + 0x1d2021, 0x282828, 0x1d2021, 0x3c3836, 0x504945, 0x665c54, + 0x928374, 0xebdbb2, 0xa89984, 0xfe8019, 0xd3869b, 0x83a598, + 0xb8bb26, 0x8ec07c, 0xfabd2f, 0xfb4934, 0x1d2021, + 0x3c3836, 0x32302f, 0x504945, 0x1d2021, 0x1d2021, 0x3c3836, 0x504945, + ]) + case .solarized: + return light ? pal([ + 0xeee8d5, 0xfdf6e3, 0xeee8d5, 0xeee8d5, 0xe2dbc6, 0xc9c0a6, + 0x657b83, 0x586e75, 0x657b83, 0x268bd2, 0x6c71c4, 0x2aa198, + 0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0xfdf6e3, + 0xeee8d5, 0xe6dfc8, 0x93a1a1, 0xeee8d5, 0xeee8d5, 0x93a1a1, 0x586e75, + ]) : pal([ + 0x002b36, 0x073642, 0x002b36, 0x586e75, 0x657b83, 0x839496, + 0x93a1a1, 0xfdf6e3, 0x93a1a1, 0x268bd2, 0x6c71c4, 0x2aa198, + 0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0x002b36, + 0x073642, 0x002b36, 0x586e75, 0x002b36, 0x002b36, 0x073642, 0x586e75, + ]) + case .everforest: + return light ? pal([ + 0xe6e2cc, 0xfdf6e3, 0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xa6b0a0, + 0x939f91, 0x5c6a72, 0x829181, 0x8da101, 0xdf69ba, 0x35a77c, + 0x8da101, 0x35a77c, 0xdfa000, 0xf85552, 0xfdf6e3, + 0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xe6e2cc, 0xe6e2cc, 0xe0dcc7, 0x939f91, + ]) : pal([ + 0x1e2326, 0x272e33, 0x2d353b, 0x374145, 0x4f5b58, 0x7a8478, + 0x859289, 0xd3c6aa, 0xa7c080, 0xa7c080, 0xd699b6, 0x7fbbb3, + 0xa7c080, 0x83c092, 0xdbbc7f, 0xe67e80, 0x272e33, + 0x374145, 0x2d353b, 0x4f5b58, 0x1e2326, 0x1e2326, 0x374145, 0x4f5b58, + ]) + case .monokai: + return light ? pal([ + 0xe6e3d4, 0xfaf8f0, 0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xb8b49e, + 0x8a8674, 0x2d2a2e, 0x5b5854, 0xe14775, 0x7c5cbf, 0x1c7d9b, + 0x5b8c2a, 0x1c7d9b, 0xb08900, 0xe14775, 0xfaf8f0, + 0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xe6e3d4, 0xe6e3d4, 0xd6d2c0, 0x8a8674, + ]) : pal([ + 0x1d1e19, 0x272822, 0x1e1f1a, 0x3e3d32, 0x49483e, 0x75715e, + 0x75715e, 0xf8f8f2, 0xa59f85, 0xf92672, 0xae81ff, 0x66d9ef, + 0xa6e22e, 0xa1efe4, 0xe6db74, 0xf92672, 0x272822, + 0x3e3d32, 0x2e2f29, 0x49483e, 0x1d1e19, 0x1d1e19, 0x3e3d32, 0x49483e, + ]) + case .oneDark: + return light ? pal([ + 0xe5e5e6, 0xfafafa, 0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7, + 0xa0a1a7, 0x383a42, 0x696c77, 0xa626a4, 0x4078f2, 0x0184bc, + 0x50a14f, 0x0184bc, 0xc18401, 0xe45649, 0xfafafa, + 0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xe5e5e6, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7, + ]) : pal([ + 0x21252b, 0x282c34, 0x21252b, 0x3b4048, 0x4b5263, 0x5c6370, + 0x5c6370, 0xabb2bf, 0x7f848e, 0xc678dd, 0x61afef, 0x56b6c2, + 0x98c379, 0x56b6c2, 0xe5c07b, 0xe06c75, 0x282c34, + 0x3b4048, 0x2c313a, 0x4b5263, 0x21252b, 0x21252b, 0x3b4048, 0x4b5263, + ]) + } +} diff --git a/ios/Maturita/Info.plist b/ios/Maturita/Info.plist new file mode 100644 index 0000000..3935383 --- /dev/null +++ b/ios/Maturita/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDisplayName + maturita.c + GITCommit + $(COMMIT) + UPDATE_REPO + pepaskopec-blip/maturita.c + UPDATE_BRANCH + builds + ITSAppUsesNonExemptEncryption + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Maturita/MaturitaApp.swift b/ios/Maturita/MaturitaApp.swift new file mode 100644 index 0000000..c40c214 --- /dev/null +++ b/ios/Maturita/MaturitaApp.swift @@ -0,0 +1,154 @@ +import SwiftUI + +@main +struct MaturitaApp: App { + @StateObject private var vm = AppModel() + + var body: some Scene { + WindowGroup { + MaturitaRoot(vm: vm) + } + } +} + +struct MaturitaRoot: View { + @ObservedObject var vm: AppModel + + var body: some View { + let p = vm.palette + ZStack { + p.base.ignoresSafeArea() + VStack(spacing: 0) { + HStack { + Text("maturita.c") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(p.text) + Spacer() + IconHit(action: { vm.searchOpen = true }) { SearchIcon(color: p.text) } + IconHit(action: { vm.go(.stats) }) { StatsIcon(color: p.text) } + IconHit(action: { vm.settingsOpen = true }) { SettingsIcon(color: p.text) } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + if vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged" { + HStack { + Text(bannerText) + .font(.system(size: 13)) + .foregroundStyle(p.text) + Spacer() + if vm.update.canInstall { + PillButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(p.surface1, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 12) + } + routeView + .id(vm.tick) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + if vm.searchOpen { + SearchOverlay(vm: vm).ignoresSafeArea(edges: .bottom) + } + if vm.settingsOpen { + SettingsSheet(vm: vm).ignoresSafeArea(edges: .bottom) + } + } + .preferredColorScheme(vm.mode == .dark ? .dark : .light) + .onAppear { vm.checkUpdate(false) } + } + + private var bannerText: String { + if let arg = vm.update.messageArg { + return vm.fmt(vm.update.messageKey, arg) + } + return vm.tr(vm.update.messageKey) + } + + @ViewBuilder + private var routeView: some View { + switch vm.route { + case .welcome: WelcomeScreen(vm: vm) + case .subjects: SubjectsScreen(vm: vm) + case .stats: StatsScreen(vm: vm) + case .roadmap: RoadmapScreen(vm: vm) + case .unitMap(let id): UnitMapScreen(vm: vm, unitId: id) + case .germanEx(let unit, let ex): GermanExercise(vm: vm, unitId: unit, ex: ex) + case .vocab(let id): VocabExercise(vm: vm, unitId: id) + case .netYears: NetYearsScreen(vm: vm) + case .netMap: + LessonMapScreen( + vm: vm, + title: vm.tr("net_year1"), + sub: vm.tr("net_sub"), + nodes: vm.content.netLessons.map { l in + let id = l.int("id") + return MapNode( + id: "n\(id)", + label: vm.tr(l.str("titleKey")), + locked: false, + done: vm.progress.netDone(id), + current: !vm.progress.netDone(id), + onClick: { vm.go(.netLesson(id)) } + ) + } + ) + case .netLesson(let id): + if let l = vm.content.netLesson(id) { + SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), sub: vm.tr(l.str("subKey")), slides: l.arr("slides")) { + vm.go(.netEx(id)) + } + } + case .netEx(let id): NetQuizScreen(vm: vm, id: id) + case .hwMap: + LessonMapScreen( + vm: vm, + title: vm.tr("Technické vybavení"), + sub: vm.tr("hw_sub"), + nodes: vm.content.hw.map { l in + let id = l.int("id") + return MapNode( + id: "h\(id)", + label: vm.tr(l.str("titleKey")), + locked: false, + done: vm.progress.hwDone(id), + current: !vm.progress.hwDone(id), + onClick: { vm.go(.hwLesson(id)) } + ) + } + ) + case .hwLesson(let id): + if let l = vm.content.hwLesson(id) { + SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), sub: vm.tr(l.str("subKey")), slides: l.arr("slides")) { + vm.go(.hwEx(id)) + } + } + case .hwEx(let id): HwQuizScreen(vm: vm, id: id) + case .czechMap: CzechMapScreen(vm: vm) + case .mluvnice: + LessonMapScreen( + vm: vm, + title: vm.tr("Mluvnice"), + sub: "Cvičení z mluvnice – styl maturita / přijímačky z ČJL", + nodes: vm.content.mluvnice.map { m in + let n = m.int("id") + return MapNode( + id: "m\(n)", + label: m.str("name"), + locked: false, + done: vm.progress.mluvDone(n), + current: !vm.progress.mluvDone(n), + onClick: { vm.go(.mluvEx(n)) } + ) + } + ) + case .mluvEx(let n): MluvExercise(vm: vm, n: n) + case .readingList: BookListScreen(vm: vm) + case .book(let id): BookScreen(vm: vm, id: id) + case .bookQuiz(let id): LitQuizScreen(vm: vm, id: id) + case .bookPlot(let id): PlotScreen(vm: vm, id: id) + } + } +} diff --git a/ios/Maturita/Route.swift b/ios/Maturita/Route.swift new file mode 100644 index 0000000..5fd8a78 --- /dev/null +++ b/ios/Maturita/Route.swift @@ -0,0 +1,98 @@ +import Foundation + +enum Route: Equatable { + case welcome + case subjects + case stats + case roadmap + case unitMap(Int) + case germanEx(Int, Int) + case vocab(Int) + case netYears + case netMap + case netLesson(Int) + case netEx(Int) + case hwMap + case hwLesson(Int) + case hwEx(Int) + case czechMap + case mluvnice + case mluvEx(Int) + case readingList + case book(String) + case bookQuiz(String) + case bookPlot(String) + + var page: String { + switch self { + case .welcome: return "welcome" + case .subjects: return "subjects" + case .stats: return "stats" + case .roadmap: return "roadmap" + case .unitMap(let unitId): return "unit\(unitId + 1)" + case .germanEx(let unitId, let ex): return "u\(unitId + 1)e\(ex)" + case .vocab(let unitId): return "u\(unitId + 1)vocab" + case .netYears: return "netyears" + case .netMap: return "netmap" + case .netLesson(let id): return "netunit\(id)" + case .netEx(let id): return "netex\(id)" + case .hwMap: return "hwmap" + case .hwLesson(let id): return "hwunit\(id)" + case .hwEx(let id): return "hwex\(id)" + case .czechMap: return "czechmap" + case .mluvnice: return "mluvnice" + case .mluvEx(let n): return "mluve\(n)" + case .readingList: return "readinglist" + case .book(let id): return id == "1984" ? "cetba1984" : "cetbaFuks" + case .bookQuiz(let id): return id == "1984" ? "cetba1984quiz" : "cetbaFuksQuiz" + case .bookPlot(let id): return id == "1984" ? "cetba1984dej" : "cetbaFuksDej" + } + } +} + +func routeFromPage(_ page: String) -> Route? { + switch page { + case "welcome": return .welcome + case "subjects": return .subjects + case "stats": return .stats + case "roadmap": return .roadmap + case "netyears": return .netYears + case "netmap": return .netMap + case "hwmap": return .hwMap + case "czechmap": return .czechMap + case "mluvnice": return .mluvnice + case "readinglist": return .readingList + case "cetba1984": return .book("1984") + case "cetba1984quiz": return .bookQuiz("1984") + case "cetba1984dej": return .bookPlot("1984") + case "cetbaFuks": return .book("fuks") + case "cetbaFuksQuiz": return .bookQuiz("fuks") + case "cetbaFuksDej": return .bookPlot("fuks") + default: + if let m = page.wholeMatch(of: /unit(\d+)/), let n = Int(m.1) { + return .unitMap(n - 1) + } + if let m = page.wholeMatch(of: /u(\d+)e(\d+)/), let u = Int(m.1), let e = Int(m.2) { + return .germanEx(u - 1, e) + } + if let m = page.wholeMatch(of: /u(\d+)vocab/), let u = Int(m.1) { + return .vocab(u - 1) + } + if let m = page.wholeMatch(of: /netunit(\d+)/), let n = Int(m.1) { + return .netLesson(n) + } + if let m = page.wholeMatch(of: /netex(\d+)/), let n = Int(m.1) { + return .netEx(n) + } + if let m = page.wholeMatch(of: /hwunit(\d+)/), let n = Int(m.1) { + return .hwLesson(n) + } + if let m = page.wholeMatch(of: /hwex(\d+)/), let n = Int(m.1) { + return .hwEx(n) + } + if let m = page.wholeMatch(of: /mluve(\d+)/), let n = Int(m.1) { + return .mluvEx(n) + } + return nil + } +} diff --git a/ios/Maturita/UI/Components.swift b/ios/Maturita/UI/Components.swift new file mode 100644 index 0000000..d020074 --- /dev/null +++ b/ios/Maturita/UI/Components.swift @@ -0,0 +1,276 @@ +import SwiftUI + +struct FlowLayout: Layout { + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + arrange(width: proposal.width ?? .infinity, subviews: subviews).size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = arrange(width: bounds.width, subviews: subviews) + for (i, frame) in result.frames.enumerated() { + subviews[i].place( + at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY), + proposal: ProposedViewSize(frame.size) + ) + } + } + + private func arrange(width: CGFloat, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) { + var frames: [CGRect] = [] + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowH: CGFloat = 0 + var maxW: CGFloat = 0 + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if x > 0 && x + size.width > width { + x = 0 + y += rowH + spacing + rowH = 0 + } + frames.append(CGRect(origin: CGPoint(x: x, y: y), size: size)) + x += size.width + spacing + rowH = max(rowH, size.height) + maxW = max(maxW, x - spacing) + } + return (CGSize(width: max(maxW, 0), height: y + rowH), frames) + } +} + +struct PrimaryButton: View { + let label: String + let palette: Palette + var action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(palette.onAccent) + .padding(.horizontal, 22) + .padding(.vertical, 12) + .background(palette.accent, in: Capsule()) + } + .buttonStyle(.plain) + } +} + +struct PillButton: View { + let label: String + let palette: Palette + var action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(palette.text) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(palette.text.opacity(0.06), in: Capsule()) + } + .buttonStyle(.plain) + } +} + +struct IconHit: View { + var action: () -> Void + @ViewBuilder var content: () -> Content + + var body: some View { + Button(action: action) { + content() + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + } +} + +struct PageTop: View { + @ObservedObject var vm: AppModel + var back: () -> Void + let title: String + var subtitle: String? + + var body: some View { + let p = vm.palette + VStack(spacing: 6) { + ZStack { + Text(title) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(p.text) + .multilineTextAlignment(.center) + .padding(.horizontal, 48) + HStack { + IconHit(action: back) { BackIcon(color: p.text) } + Spacer() + } + } + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 14)) + .foregroundStyle(p.subtext) + .multilineTextAlignment(.center) + } + } + .padding(.top, 4) + .padding(.bottom, 14) + } +} + +struct CardBox: View { + let palette: Palette + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(palette.text.opacity(0.07), lineWidth: 1) + ) + } +} + +struct FeedbackLine: View { + let text: String + let kind: String + let palette: Palette + + var body: some View { + if !text.isEmpty { + Text(text) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error) + .padding(.top, 8) + } + } +} + +struct Meaning: View { + let text: String + let palette: Palette + let visible: Bool + + var body: some View { + if visible && !text.isEmpty { + Text(text) + .font(.system(size: 13)) + .foregroundStyle(palette.subtext) + .padding(.bottom, 8) + } + } +} + +struct WordField: View { + @Binding var value: String + let palette: Palette + var placeholder: String + var mark: Bool? + + var body: some View { + let border: Color = { + if mark == true { return palette.success } + if mark == false { return palette.error } + return palette.text.opacity(0.12) + }() + ZStack(alignment: .leading) { + if value.isEmpty { + Text(placeholder).foregroundStyle(palette.overlay).font(.system(size: 15)) + } + TextField("", text: $value) + .font(.system(size: 15)) + .foregroundStyle(palette.text) + .tint(palette.accent) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(palette.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1)) + } +} + +struct SegChip: View { + let label: String + let selected: Bool + let palette: Palette + var action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(palette.text) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(selected ? palette.mantle : Color.clear, in: Capsule()) + } + .buttonStyle(.plain) + } +} + +struct Hint: View { + let text: String + let palette: Palette + var body: some View { + Text(text).font(.system(size: 13)).foregroundStyle(palette.subtext).padding(.bottom, 8) + } +} + +struct Prompt: View { + let text: String + let palette: Palette + var body: some View { + Text(text).font(.system(size: 16, weight: .medium)).foregroundStyle(palette.text) + } +} + +struct WordChip: View { + let text: String + let palette: Palette + var action: () -> Void + + var body: some View { + Button(action: action) { + Text(text) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(palette.text) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(palette.mantle, in: Capsule()) + .overlay(Capsule().stroke(palette.text.opacity(0.1), lineWidth: 1)) + } + .buttonStyle(.plain) + } +} + +struct OptionChip: View { + let text: String + let selected: Bool + let mark: Bool? + let palette: Palette + var action: () -> Void + + var body: some View { + let border: Color = { + if mark == true { return palette.success } + if mark == false { return palette.error } + return selected ? palette.accent : palette.text.opacity(0.1) + }() + Button(action: action) { + Text(text) + .font(.system(size: 15)) + .foregroundStyle(palette.text) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(selected ? palette.surface1 : palette.mantle, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1)) + } + .buttonStyle(.plain) + .padding(.bottom, 6) + } +} diff --git a/ios/Maturita/UI/Exercises.swift b/ios/Maturita/UI/Exercises.swift new file mode 100644 index 0000000..300102c --- /dev/null +++ b/ios/Maturita/UI/Exercises.swift @@ -0,0 +1,608 @@ +import SwiftUI + +struct ExerciseScaffold: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let action: String + var onAction: () -> Void + let feedback: String + let kind: String + @ViewBuilder var content: () -> Content + + var body: some View { + let p = vm.palette + VStack(alignment: .leading, spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) + ScrollView { + VStack(alignment: .leading, spacing: 0) { content() } + } + FeedbackLine(text: feedback, kind: kind, palette: p) + PrimaryButton(label: action, palette: p, action: onAction) + .padding(.vertical, 12) + } + .padding(.horizontal, 16) + } +} + +struct GermanExercise: View { + @ObservedObject var vm: AppModel + let unitId: Int + let ex: Int + + var body: some View { + if let spec = vm.content.germanUnit(unitId)?.exercise(ex) { + DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) { + vm.markGerman(unitId, ex) + } + } + } +} + +struct VocabExercise: View { + @ObservedObject var vm: AppModel + let unitId: Int + + var body: some View { + if let spec = vm.content.germanUnit(unitId)?.obj("vocab") { + DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr("Vokabeltraining")) { + vm.markVocab(unitId) + } + } + } +} + +struct MluvExercise: View { + @ObservedObject var vm: AppModel + let n: Int + + var body: some View { + if let spec = vm.content.mluv(n) { + DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) { + vm.markMluv(n) + } + } + } +} + +struct DispatchExercise: View { + @ObservedObject var vm: AppModel + let spec: J + let titleFallback: String + var onDone: () -> Void + + var body: some View { + let title = spec.str("title").isEmpty ? titleFallback : spec.str("title") + let sub = spec.strOrNull("sub").map { vm.tr($0) } + switch spec.str("type") { + case "choice": ChoiceEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "assembly": AssemblyEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "typed", "kw": TypedEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "free", "profile": FreeEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "assign": AssignEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "hangman": HangmanEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "vocab": VocabEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "dialog": DialogEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "number", "count", "seq", "verb", "verbclue", "verb_sections", "ordne", "fill": + ComboEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "ex2": Ex2Ex(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "letters", "letters_gap": LettersEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "table": TableEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + case "reveal": RevealEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) + default: Text("?").foregroundStyle(vm.palette.text) + } + } +} + +private struct ChoiceEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + @State private var picks: [Int] + @State private var fb = ("", "") + @State private var revealed = false + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + _picks = State(initialValue: Array(repeating: -1, count: spec.arr("questions").count)) + } + + var body: some View { + let qs = spec.arr("questions") + let meanings = spec.strs("meanings") + let expls = spec.strs("expls") + let p = vm.palette + ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { + let ok = qs.indices.filter { picks[$0] == qs[$0].int("correct") }.count + if ok == qs.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() } + else { fb = (vm.tr("feedback_retry_short"), "err") } + revealed = true + }, feedback: fb.0, kind: fb.1) { + ForEach(qs.indices, id: \.self) { i in + Text("\(i + 1). \(qs[i].str("prompt"))") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(p.text) + .padding(.bottom, 6) + ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in + let sel = picks[i] == o + let mark: Bool? = revealed && sel ? picks[i] == qs[i].int("correct") : nil + OptionChip(text: opt, selected: sel, mark: mark, palette: p) { picks[i] = o } + } + if revealed { + if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) } + if i < expls.count { Meaning(text: expls[i], palette: p, visible: true) } + } + Spacer().frame(height: 10) + } + } + } +} + +private struct AssemblyEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + @State private var placed: [[Int]] + @State private var pools: [[Int]] + @State private var fb = ("", "") + @State private var revealed = false + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + let items = spec.arr("items") + _placed = State(initialValue: items.map { _ in [] }) + _pools = State(initialValue: items.map { item in + Array(0.. Void + @State private var fields: [[String]] + @State private var fb = ("", "") + @State private var revealed = false + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows") + _fields = State(initialValue: items.map { q in + Array(repeating: "", count: max(1, q.strs("hints").count, q.answerList().count)) + }) + } + + var body: some View { + let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows") + let p = vm.palette + let umlaut = items.contains { q in + q.answerList().contains { hasUmlaut($0) } || hasUmlaut(q.str("prompt")) + } + ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { + var ok = 0, total = 0, incomplete = false + for (i, q) in items.enumerated() { + let answers = q.answerList() + for (j, value) in fields[i].enumerated() { + total += 1 + let ans = j < answers.count ? answers[j] : "" + let norm = normalizeAnswer(value) + if answerAccepts(norm, ans) { ok += 1 } + else if answerIncomplete(norm, ans) { incomplete = true } + } + } + if total > 0 && ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() } + else if incomplete { fb = (vm.tr("trans_incomplete"), "warn") } + else { fb = (vm.tr("feedback_retry"), "err") } + revealed = true + }, feedback: fb.0, kind: fb.1) { + if let note = spec.strOrNull("note") { Hint(text: note, palette: p) } + if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: p) } + if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)", palette: p) } + if umlaut { Hint(text: vm.tr("hint_umlauts"), palette: p) } + ForEach(items.indices, id: \.self) { i in + let q = items[i] + Prompt(text: q.str("prompt"), palette: p) + let answers = q.answerList() + let hints = q.strs("hints") + ForEach(fields[i].indices, id: \.self) { j in + let ans = j < answers.count ? answers[j] : "" + WordField( + value: Binding(get: { fields[i][j] }, set: { fields[i][j] = $0 }), + palette: p, + placeholder: j < hints.count ? hints[j] : vm.tr("your_answer"), + mark: revealed ? answerAccepts(normalizeAnswer(fields[i][j]), ans) : nil + ) + .padding(.bottom, 4) + } + if revealed { + if let m = q.strOrNull("meaning") { Meaning(text: vm.tr(m), palette: p, visible: true) } + if let m = q.strOrNull("mean") { Meaning(text: vm.tr(m), palette: p, visible: true) } + if let g = q.strOrNull("german") { Meaning(text: g, palette: p, visible: true) } + if let s = q.strOrNull("shown") { Meaning(text: s, palette: p, visible: true) } + } + Spacer().frame(height: 10) + } + } + } +} + +private struct FreeEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + @State private var shown: [Bool] + @State private var values: [String] + @State private var fb = ("", "") + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions") + _shown = State(initialValue: Array(repeating: false, count: qs.count)) + _values = State(initialValue: Array(repeating: "", count: qs.count)) + } + + var body: some View { + let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions") + let p = vm.palette + ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: { + fb = (vm.tr("feedback_ok"), "ok") + shown = shown.map { _ in true } + onDone() + }, feedback: fb.0, kind: fb.1) { + if let tip = spec.strOrNull("tip") { Hint(text: vm.tr(tip), palette: p) } + ForEach(qs.indices, id: \.self) { i in + let q = qs[i] + Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question"), palette: p) + WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), palette: p, placeholder: vm.tr("your_answer")) + PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() } + .padding(.vertical, 6) + if shown[i] { + let sample = q.str("sample") + let qcs = q.str("qCs") + let acs = q.str("aCs") + Meaning( + text: qcs.isEmpty ? sample : vm.fmt("sample_fmt", vm.tr(qcs), sample, vm.tr(acs)), + palette: p, + visible: true + ) + } + Spacer().frame(height: 10) + } + } + } +} + +private struct AssignEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + @State private var active = 0 + @State private var loc: [Int] + @State private var fb = ("", "") + @State private var revealed = false + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + _loc = State(initialValue: Array(repeating: -1, count: spec.arr("items").count)) + } + + var body: some View { + let items = spec.arr("items") + let groups = spec.strs("groups") + let meanings = spec.strs("meanings") + let p = vm.palette + ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { + let ok = items.indices.allSatisfy { loc[$0] == items[$0].int("group") } + if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() } + else { fb = (vm.tr("feedback_retry"), "err") } + revealed = true + }, feedback: fb.0, kind: fb.1) { + Hint(text: vm.tr("assign_hint"), palette: p) + FlowLayout(spacing: 8) { + ForEach(groups.indices, id: \.self) { i in + SegChip(label: groups[i], selected: active == i, palette: p) { active = i } + } + } + Text(vm.tr("wordbank")).font(.system(size: 12)).foregroundStyle(p.subtext).padding(.top, 10) + FlowLayout(spacing: 6) { + ForEach(items.indices, id: \.self) { i in + if loc[i] == -1 { + let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ") + WordChip(text: label, palette: p) { loc[i] = active } + } + } + } + ForEach(groups.indices, id: \.self) { g in + Text(groups[g]).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 10) + FlowLayout(spacing: 6) { + ForEach(items.indices, id: \.self) { i in + if loc[i] == g { + let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ") + WordChip(text: label, palette: p) { loc[i] = -1 } + } + } + } + } + if revealed { + ForEach(items.indices, id: \.self) { i in + if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) } + } + } + } + } +} + +private struct HangmanEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + @State private var word = 0 + @State private var misses = 0 + @State private var guessed: [Bool] + @State private var fb = ("", "") + @State private var finished = false + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + _guessed = State(initialValue: Array(repeating: false, count: spec.strs("letters").count)) + } + + var body: some View { + let words = spec.strs("words") + let tips = spec.strs("tips") + let letters = spec.strs("letters") + let p = vm.palette + let cur = word < words.count ? words[word].uppercased() : "" + VStack(alignment: .leading, spacing: 8) { + PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) + Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(p.subtext) + if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))", palette: p) } + Canvas { ctx, size in + var gallows = Path() + gallows.move(to: CGPoint(x: 40, y: size.height - 10)) + gallows.addLine(to: CGPoint(x: size.width * 0.55, y: size.height - 10)) + gallows.move(to: CGPoint(x: 70, y: size.height - 10)) + gallows.addLine(to: CGPoint(x: 70, y: 16)) + gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, y: 16)) + ctx.stroke(gallows, with: .color(p.text), style: StrokeStyle(lineWidth: 6, lineCap: .round)) + let x = size.width * 0.3 + 40 + if misses > 0 { + var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36)) + ctx.stroke(rope, with: .color(p.text), lineWidth: 4) + } + if misses > 1 { + ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(p.text), lineWidth: 4) + } + if misses > 2 { + var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110)) + ctx.stroke(body, with: .color(p.text), lineWidth: 4) + } + if misses > 3 { + var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100)) + ctx.stroke(a, with: .color(p.text), lineWidth: 4) + } + if misses > 4 { + var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100)) + ctx.stroke(a, with: .color(p.text), lineWidth: 4) + } + if misses > 5 { + var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140)) + ctx.stroke(a, with: .color(p.text), lineWidth: 4) + } + } + .frame(height: 160) + HStack(spacing: 6) { + ForEach(Array(cur.enumerated()), id: \.offset) { _, ch in + let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame } + let show = idx.map { guessed[$0] } ?? false + Text(show ? String(ch) : "_") + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(p.text) + } + } + ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in + HStack(spacing: 4) { + ForEach(row, id: \.self) { letter in + if let i = letters.firstIndex(of: letter) { + Button { + guessed[i] = true + if !cur.localizedCaseInsensitiveContains(letter) { + misses += 1 + if misses >= 6 { fb = (vm.fmt("hm_fail", cur), "err") } + } else if cur.allSatisfy({ ch in + let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame } + return idx.map { guessed[$0] } ?? false + }) { + if word + 1 >= words.count { + finished = true + fb = (vm.tr("hm_done"), "ok") + onDone() + } else { + fb = (vm.tr("hm_wrong"), "ok") + word += 1 + misses = 0 + guessed = guessed.map { _ in false } + } + } + } label: { + Text(letter) + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(p.text) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(guessed[i] ? p.surface1 : p.mantle, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(guessed[i] || finished) + } + } + } + } + FeedbackLine(text: fb.0, kind: fb.1, palette: p) + if misses >= 6 && !finished { + PillButton(label: vm.tr("hm_retry"), palette: p) { + misses = 0 + guessed = guessed.map { _ in false } + fb = ("", "") + } + } + Spacer() + } + .padding(.horizontal, 16) + } +} + +private extension Array { + func chunked(_ size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { Array(self[$0.. Void + @State private var cards: [(String, String, String)] + @State private var index = 0 + @State private var done = 0 + @State private var value = "" + @State private var fb = ("", "") + let total: Int + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + let list = spec.arr("sections").flatMap { sec in + sec.arr("rows").map { (sec.str("header"), $0.str("prompt"), $0.str("answers")) } + } + _cards = State(initialValue: list) + total = list.count + } + + var body: some View { + let p = vm.palette + VStack(alignment: .leading, spacing: 8) { + PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) + Text(vm.fmt("trans_progress", done, total)).foregroundStyle(p.subtext) + if cards.isEmpty { + Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success) + } else if index < cards.count { + let cur = cards[index] + Hint(text: cur.0, palette: p) + Prompt(text: cur.1, palette: p) + if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts"), palette: p) } + WordField(value: $value, palette: p, placeholder: vm.tr("your_answer")) + PrimaryButton(label: vm.tr("check"), palette: p) { + let norm = normalizeAnswer(value) + if answerAccepts(norm, cur.2) { + done += 1 + cards.remove(at: index) + if cards.isEmpty { fb = (vm.tr("trans_done"), "ok"); onDone() } + else { if index >= cards.count { index = 0 }; value = ""; fb = ("", "") } + } else if answerIncomplete(norm, cur.2) { + fb = (vm.tr("trans_incomplete"), "warn") + } else { + fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err") + let item = cards.remove(at: index) + cards.append(item) + if index >= cards.count { index = 0 } + value = "" + } + } + FeedbackLine(text: fb.0, kind: fb.1, palette: p) + } + Spacer() + } + .padding(.horizontal, 16) + } +} diff --git a/ios/Maturita/UI/ExercisesMore.swift b/ios/Maturita/UI/ExercisesMore.swift new file mode 100644 index 0000000..9e49d74 --- /dev/null +++ b/ios/Maturita/UI/ExercisesMore.swift @@ -0,0 +1,857 @@ +import SwiftUI + +struct DialogEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + + var body: some View { + let pool = spec.strs("pool") + let meanings = spec.strs("meanings") + let lines = dialogLines() + ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: lines.count, onDone: onDone) { picks, expected, revealed in + ForEach(lines.indices, id: \.self) { i in + let line = lines[i] + if let name = line.name { + Text(name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(vm.palette.subtext) + .padding(.vertical, 6) + } + ComboLine( + vm: vm, pool: pool, picks: picks, expected: expected, idx: i, + answer: line.answer, + prefix: line.prefix, + suffix: line.suffix, + meaning: i < meanings.count ? meanings[i] : nil, + revealed: revealed + ) + } + } + } + + private func dialogLines() -> [(name: String?, prefix: String, answer: String, suffix: String)] { + var out: [(name: String?, prefix: String, answer: String, suffix: String)] = [] + for d in spec.arr("dialogues") { + var header: String? = d.str("name") + for row in d.arr("rows") { + out.append(( + header, + [row.str("speaker"), row.str("before")].filter { !$0.isEmpty }.joined(separator: " "), + row.str("answer"), + row.str("after") + )) + header = nil + } + } + return out + } +} + +struct ComboEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + + var body: some View { + let pool = spec.strs("pool") + let rows = spec.arr("rows") + let meanings = spec.strs("meanings") + let type = spec.str("type") + if type == "verb_sections" { + let sections = spec.arr("sections") + let allRows = sections.flatMap { $0.arr("rows") } + ComboRows(vm: vm, title: title, sub: sub, pool: spec.strs("pool"), n: allRows.count, onDone: onDone, revealMean: spec.bool("transUpfront")) { picks, expected, revealed in + ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in + if let title = line.title { + Text(title).font(.system(size: 15, weight: .semibold)).foregroundStyle(vm.palette.subtext) + } + ComboLine( + vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx, + answer: line.answer, prefix: line.before, suffix: line.after, + meaning: line.meaning, + revealed: revealed || spec.bool("transUpfront") + ) + } + } + } else if type == "ordne" { + ComboRows(vm: vm, title: title, sub: sub, pool: [], n: rows.count * 2, onDone: onDone) { picks, expected, revealed in + ForEach(rows.indices, id: \.self) { i in + ComboLine(vm: vm, pool: spec.strs("fwPool"), picks: picks, expected: expected, idx: i * 2, answer: rows[i].str("fw"), prefix: "\(i + 1). ", suffix: rows[i].str("mid"), revealed: revealed) + ComboLine(vm: vm, pool: spec.strs("ansPool"), picks: picks, expected: expected, idx: i * 2 + 1, answer: rows[i].str("ans"), prefix: "→ ", meaning: i < meanings.count ? meanings[i] : nil, revealed: revealed) + } + } + } else if type == "fill" { + let blanks = rows.reduce(0) { $0 + $1.answerList().count } + ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in + if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) } + ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in + FlowLayout(spacing: 4) { + if let num = block.num { + Text(num).font(.system(size: 16, weight: .semibold)).foregroundStyle(vm.palette.text) + } + ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in + switch part { + case .text(let s): + Text(s).foregroundStyle(vm.palette.text) + case .blank(let idx, let ans): + ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true) + } + } + if let mean = block.meaning { + Text(vm.tr(mean)).font(.system(size: 12)).foregroundStyle(vm.palette.subtext) + } + } + .padding(.bottom, 6) + if revealed || spec.bool("preMeaning") || spec.bool("showGerman"), let mean = block.rowMean { + Meaning(text: vm.tr(mean), palette: vm.palette, visible: true) + } + } + } + } else { + ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: rows.count, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in + if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) } + ForEach(rows.indices, id: \.self) { i in + let r = rows[i] + let prefix: String = { + switch type { + case "number": return "\(r.str("digits")) = " + case "count": return "\(Array(repeating: r.str("emoji"), count: r.int("count")).joined(separator: " ")) \(r.str("noun")) " + case "seq": return r.str("before") + case "verbclue": return "\(r.str("emoji")) \(r.str("before"))" + default: return r.str("before") + } + }() + let suffix: String = { + switch type { + case "seq", "verbclue", "verb": return r.str("after") + default: return "" + } + }() + ComboLine( + vm: vm, pool: pool, picks: picks, expected: expected, idx: i, answer: r.str("answer"), + prefix: prefix, suffix: suffix, + meaning: i < meanings.count ? meanings[i] : r.strOrNull("mean"), + revealed: revealed || spec.bool("preMeaning") + ) + } + } + } + } + + private func verbSectionLines() -> [(title: String?, before: String, answer: String, after: String, meaning: String?)] { + var out: [(title: String?, before: String, answer: String, after: String, meaning: String?)] = [] + for sec in spec.arr("sections") { + var header: String? = sec.str("title") + let meanings = sec.strs("meanings") + for (ri, row) in sec.arr("rows").enumerated() { + out.append(( + header, + row.str("before"), + row.str("answer"), + row.str("after"), + ri < meanings.count ? meanings[ri] : nil + )) + header = nil + } + } + return out + } + + private enum FillPart { + case text(String) + case blank(Int, String) + } + + private func fillRows() -> [(num: String?, parts: [FillPart], meaning: String?, rowMean: String?)] { + let meanings = spec.strs("meanings") + var i = 0 + return spec.arr("rows").enumerated().map { ri, r in + var parts: [FillPart] = [] + let segs = r.strsOrEmpty("segs") + let ans = r.answerList() + for si in segs.indices { + if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) } + if si < ans.count { + parts.append(.blank(i, ans[si])) + i += 1 + } + } + return ( + r.strOrNull("num"), + parts, + ri < meanings.count ? meanings[ri] : nil, + r.strOrNull("mean") + ) + } + } +} + +private struct ComboRows: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let pool: [String] + let n: Int + var onDone: () -> Void + var revealMean = false + @ViewBuilder var bodyContent: (Binding<[String]>, Binding<[String]>, Bool) -> Body + @State private var picks: [String] + @State private var expected: [String] + @State private var fb = ("", "") + @State private var revealed: Bool + + init( + vm: AppModel, + title: String, + sub: String?, + pool: [String], + n: Int, + onDone: @escaping () -> Void, + revealMean: Bool = false, + @ViewBuilder bodyContent: @escaping (Binding<[String]>, Binding<[String]>, Bool) -> Body + ) { + self.vm = vm + self.title = title + self.sub = sub + self.pool = pool + self.n = n + self.onDone = onDone + self.revealMean = revealMean + self.bodyContent = bodyContent + _picks = State(initialValue: Array(repeating: "", count: n)) + _expected = State(initialValue: Array(repeating: "", count: n)) + _revealed = State(initialValue: revealMean) + } + + var body: some View { + ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { + let ok = expected.indices.allSatisfy { i in + let exp = expected[i] + return exp.isEmpty || answerAccepts(normalizeAnswer(picks[i]), exp) + } + if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() } + else { fb = (vm.tr("feedback_retry"), "err") } + revealed = true + }, feedback: fb.0, kind: fb.1) { + bodyContent($picks, $expected, revealed) + } + } +} + +private struct ComboLine: View { + @ObservedObject var vm: AppModel + let pool: [String] + @Binding var picks: [String] + @Binding var expected: [String] + let idx: Int + let answer: String + var prefix: String = "" + var suffix: String = "" + var meaning: String? + var revealed: Bool + var inline = false + @State private var open = false + + var body: some View { + let p = vm.palette + let pick = idx < picks.count ? picks[idx] : "" + let mark: Bool? = { + if revealed && !pick.isEmpty { return answerAccepts(normalizeAnswer(pick), answer) } + return nil + }() + let chip = Button { + open = true + } label: { + Text(pick.isEmpty ? "—" : pick) + .font(.system(size: 15)) + .foregroundStyle(pick.isEmpty ? p.overlay : p.text) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(p.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(mark == true ? p.success : mark == false ? p.error : p.text.opacity(0.12), lineWidth: 1) + ) + } + .buttonStyle(.plain) + + Group { + if inline { + chip + } else { + FlowLayout(spacing: 6) { + if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) } + chip + if !suffix.isEmpty { Text(suffix).font(.system(size: 16)).foregroundStyle(p.text) } + } + .padding(.bottom, 8) + } + } + .onAppear { + if idx >= 0 && idx < expected.count { expected[idx] = answer } + } + .onChange(of: answer) { new in + if idx >= 0 && idx < expected.count { expected[idx] = new } + } + .sheet(isPresented: $open) { + WordPicker(palette: p, pool: pool) { word in + if let word, idx < picks.count { picks[idx] = word } + open = false + } + .presentationDetents([.medium, .large]) + } + if revealed, let meaning, !meaning.isEmpty { + Meaning(text: vm.tr(meaning), palette: p, visible: true) + } + } +} + +struct WordPicker: View { + let palette: Palette + let pool: [String] + var onPick: (String?) -> Void + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(pool, id: \.self) { w in + Button { onPick(w) } label: { + Text(w) + .font(.system(size: 16)) + .foregroundStyle(palette.text) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + } + .padding(10) + } + .background(palette.base) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel) { onPick(nil) } label: { Text("Zavřít") } + } + } + } + } +} + +struct Ex2Ex: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + + var body: some View { + let items = spec.arr("items") + let pool = spec.strs("pool") + let blanks = items.reduce(0) { acc, item in + acc + item.arr("rows").reduce(0) { $0 + $1.strs("answers").count } + } + ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone) { picks, expected, revealed in + ForEach(Array(ex2Blocks().enumerated()), id: \.offset) { _, block in + Meaning(text: block.czech, palette: vm.palette, visible: true) + ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in + FlowLayout(spacing: 4) { + if let num = row.num { + Text(num).foregroundStyle(vm.palette.text).padding(.trailing, 6) + } + ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in + switch part { + case .text(let s): + Text(s).foregroundStyle(vm.palette.text) + case .blank(let idx, let ans): + ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true) + } + } + } + } + } + } + } + + private enum Part { + case text(String) + case blank(Int, String) + } + + private func ex2Blocks() -> [(czech: String, rows: [(num: String?, parts: [Part])])] { + var i = 0 + return spec.arr("items").map { item in + let rows = item.arr("rows").map { row -> (num: String?, parts: [Part]) in + var parts: [Part] = [] + let segs = row.strsOrEmpty("segs") + let ans = row.strs("answers") + for si in segs.indices { + if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) } + if si < ans.count { + parts.append(.blank(i, ans[si])) + i += 1 + } + } + return (row.strOrNull("num"), parts) + } + return (item.str("czech"), rows) + } + } +} + +struct LettersEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + @State private var fields: [String] + @State private var fb = ("", "") + @State private var revealed = false + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + let n = spec.arr("rows").flatMap { $0.strs("answers") }.count + _fields = State(initialValue: Array(repeating: "", count: n)) + } + + var body: some View { + let rows = spec.arr("rows") + let p = vm.palette + ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { + var i = 0, ok = 0, total = 0 + for r in rows { + for ans in r.strs("answers") { + total += 1 + if i < fields.count && answerAccepts(normalizeAnswer(fields[i]), ans) { ok += 1 } + i += 1 + } + } + if ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() } + else { fb = (vm.tr("feedback_retry"), "err") } + revealed = true + }, feedback: fb.0, kind: fb.1) { + if rows.contains(where: { $0.strs("answers").contains { hasUmlaut($0) } }) { + Hint(text: vm.tr("hint_umlauts"), palette: p) + } + LettersRows(vm: vm, rows: rows, fields: $fields, revealed: revealed) + } + } +} + +private struct LettersRows: View { + @ObservedObject var vm: AppModel + let rows: [J] + @Binding var fields: [String] + let revealed: Bool + + var body: some View { + let p = vm.palette + let pairs = build() + VStack(alignment: .leading, spacing: 8) { + ForEach(pairs.indices, id: \.self) { ri in + let row = pairs[ri] + if let num = row.num { Text(num).foregroundStyle(p.subtext) } + FlowLayout { + ForEach(row.parts.indices, id: \.self) { pi in + switch row.parts[pi] { + case .text(let s): + Text(s).foregroundStyle(p.text) + case .field(let idx, let ans): + WordField( + value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }), + palette: p, + placeholder: "", + mark: revealed ? answerAccepts(normalizeAnswer(idx < fields.count ? fields[idx] : ""), ans) : nil + ) + .frame(width: 72) + } + } + } + if revealed, let czech = row.czech { Meaning(text: czech, palette: p, visible: true) } + if revealed, let mean = row.mean { Meaning(text: mean, palette: p, visible: true) } + } + } + } + + private enum Part { + case text(String) + case field(Int, String) + } + + private struct RowModel { + var num: String? + var parts: [Part] + var czech: String? + var mean: String? + } + + private func build() -> [RowModel] { + var i = 0 + return rows.map { r in + var parts: [Part] = [] + let segs = r.strsOrEmpty("segs") + let ans = r.strs("answers") + for si in segs.indices { + if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) } + if si < ans.count { + parts.append(.field(i, ans[si])) + i += 1 + } + } + return RowModel(num: r.strOrNull("num"), parts: parts, czech: r.strOrNull("czech"), mean: r.strOrNull("mean")) + } + } +} + +struct TableEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + @State private var fields: [String] + @State private var fb = ("", "") + @State private var revealed = false + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + _fields = State(initialValue: Array(repeating: "", count: spec.strs("nouns").count * spec.strs("persons").count)) + } + + var body: some View { + let persons = spec.strs("persons") + let nouns = spec.strs("nouns") + let answers = spec.intRows("answers") + let p = vm.palette + ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { + var ok = 0 + for (r, row) in answers.enumerated() { + for (c, ans) in row.enumerated() { + let i = r * persons.count + c + if i < fields.count && normalizeAnswer(fields[i]) == normalizeAnswer(ans) { ok += 1 } + } + } + if ok == fields.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() } + else { fb = (vm.tr("feedback_retry"), "err") } + revealed = true + }, feedback: fb.0, kind: fb.1) { + ForEach(nouns.indices, id: \.self) { r in + Text(nouns[r]).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) + ForEach(persons.indices, id: \.self) { c in + let i = r * persons.count + c + Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext) + WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), palette: p, placeholder: "") + } + Spacer().frame(height: 8) + } + } + } +} + +struct RevealEx: View { + @ObservedObject var vm: AppModel + let title: String + var sub: String? + let spec: J + var onDone: () -> Void + @State private var shown: [Bool] + @State private var fb = ("", "") + + init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) { + self.vm = vm + self.title = title + self.sub = sub + self.spec = spec + self.onDone = onDone + _shown = State(initialValue: Array(repeating: false, count: spec.arr("items").count)) + } + + var body: some View { + let items = spec.arr("items") + let p = vm.palette + ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: { + shown = shown.map { _ in true } + fb = (vm.tr("feedback_ok"), "ok") + onDone() + }, feedback: fb.0, kind: fb.1) { + if let note = spec.strOrNull("note") { Hint(text: note, palette: p) } + ForEach(items.indices, id: \.self) { i in + Prompt(text: items[i].str("prompt"), palette: p) + PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() } + .padding(.vertical, 6) + if shown[i] { Meaning(text: items[i].str("solution"), palette: p, visible: true) } + Spacer().frame(height: 8) + } + } + } +} + +struct NetQuizScreen: View { + @ObservedObject var vm: AppModel + let id: Int + + var body: some View { + if let lesson = vm.content.netLesson(id) { + if id == 1 { + VlsmEx(vm: vm, lesson: lesson) + } else { + let spec = J([ + "type": "choice", + "title": vm.tr(lesson.str("exTitleKey")), + "sub": "net_quiz_intro", + "questions": lesson.rawArray("quiz"), + ]) + DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) { + vm.markNet(id) + } + } + } + } +} + +struct HwQuizScreen: View { + @ObservedObject var vm: AppModel + let id: Int + + var body: some View { + if let lesson = vm.content.hwLesson(id) { + let spec = J([ + "type": "choice", + "title": vm.tr(lesson.str("exTitleKey")), + "sub": lesson.str("quizHeadKey"), + "questions": lesson.rawArray("quiz"), + ]) + DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) { + vm.markHw(id) + } + } + } +} + +private struct VlsmRow { + var pfx = "" + var net = "" + var bc = "" + var lo = "" + var hi = "" +} + +private struct VlsmEx: View { + @ObservedObject var vm: AppModel + let lesson: J + @State private var done: [Bool] + @State private var rows: [[VlsmRow]] + @State private var fb = ("", "") + + init(vm: AppModel, lesson: J) { + self.vm = vm + self.lesson = lesson + let answers = vm.content.netAnswers + _done = State(initialValue: Array(repeating: false, count: vm.content.netTasks.count)) + _rows = State(initialValue: answers.map { block in Array(repeating: VlsmRow(), count: block.count) }) + } + + var body: some View { + let tasks = vm.content.netTasks + let answers = vm.content.netAnswers + let p = vm.palette + VStack(alignment: .leading, spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_ex_title"), subtitle: nil) + ScrollView { + VStack(alignment: .leading, spacing: 12) { + ForEach(tasks.indices, id: \.self) { ti in + let task = tasks[ti] + let block = ti < answers.count ? answers[ti] : [] + let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 } + Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8) + if infeasible { + Hint(text: vm.tr("net_infeasible"), palette: p) + } else { + ForEach(block.indices, id: \.self) { si in + Text("Podsíť \(String(UnicodeScalar(65 + si)!))") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(p.subtext) + PrefixMenu(value: Binding( + get: { rows[ti][si].pfx }, + set: { rows[ti][si].pfx = $0 } + ), palette: p) + WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), palette: p, placeholder: "síť") + WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), palette: p, placeholder: "broadcast") + WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), palette: p, placeholder: "první uzel") + WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), palette: p, placeholder: "poslední uzel") + } + PillButton(label: vm.tr("check"), palette: p) { + let ok = block.indices.allSatisfy { si in + let a = block[si] + let field = rows[ti][si] + return Int(field.pfx.replacingOccurrences(of: "/", with: "")) == a.int("pfx") + && netTxtEq(field.net, a.str("net")) + && netTxtEq(field.bc, a.str("bcast")) + && netTxtEq(field.lo, a.str("lo")) + && netTxtEq(field.hi, a.str("hi")) + } + if ok { + done[ti] = true + fb = (vm.tr("feedback_ok"), "ok") + maybeFinish() + } else { + fb = (vm.tr("feedback_retry"), "err") + } + } + .padding(.vertical, 6) + } + PillButton(label: vm.tr("net_solution"), palette: p) { + if !infeasible { + for si in block.indices { + rows[ti][si].pfx = "/\(block[si].int("pfx"))" + rows[ti][si].net = block[si].str("net") + rows[ti][si].bc = block[si].str("bcast") + rows[ti][si].lo = block[si].str("lo") + rows[ti][si].hi = block[si].str("hi") + } + } + done[ti] = true + fb = ("", "") + maybeFinish() + } + if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) } + } + } + } + FeedbackLine(text: fb.0, kind: fb.1, palette: p) + } + .padding(.horizontal, 16) + } + + private func maybeFinish() { + if !done.isEmpty && done.allSatisfy({ $0 }) { vm.markNet(1) } + } +} + +private struct PrefixMenu: View { + @Binding var value: String + let palette: Palette + @State private var open = false + + var body: some View { + Button { open = true } label: { + Text(value.isEmpty ? "/…" : value) + .font(.system(size: 15)) + .foregroundStyle(palette.text) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.vertical, 4) + .sheet(isPresented: $open) { + WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in + if let word { value = word } + open = false + } + .presentationDetents([.medium, .large]) + } + } +} + +struct LitQuizScreen: View { + @ObservedObject var vm: AppModel + let id: String + @State private var idx = 0 + @State private var score = 0 + @State private var answered = false + @State private var finished = false + + var body: some View { + if let book = vm.content.book(id) { + let qs = book.arr("quiz") + let p = vm.palette + let kahoot = [p.accent, p.error, p.warning, p.success] + VStack(alignment: .leading, spacing: 8) { + PageTop(vm: vm, back: { vm.back() }, title: vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub"))) + if finished { + Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text) + Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext) + Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) + PrimaryButton(label: vm.tr("lit_again"), palette: p) { + idx = 0; score = 0; answered = false; finished = false + } + } else if idx < qs.count { + let q = qs[idx] + HStack { + Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)).foregroundStyle(p.subtext) + Spacer() + Text(vm.fmt("lit_score_fmt", score, qs.count)).foregroundStyle(p.subtext) + } + Text(q.str("prompt")).font(.system(size: 18, weight: .semibold)).foregroundStyle(p.text).padding(.vertical, 12) + ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in + Button { + answered = true + if i == q.int("correct") { score += 1 } + } label: { + Text(opt) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(p.onAccent) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(kahoot[i % 4], in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(answered) + .padding(.bottom, 8) + } + if answered { + Meaning(text: q.str("expl"), palette: p, visible: true) + PrimaryButton(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), palette: p) { + if idx + 1 >= qs.count { + finished = true + vm.markBookQuiz(id) + } else { + idx += 1 + answered = false + } + } + } + } + Spacer() + } + .padding(16) + } + } +} + +struct PlotScreen: View { + @ObservedObject var vm: AppModel + let id: String + + var body: some View { + if let book = vm.content.book(id) { + let spec = J([ + "type": "assembly", + "title": vm.tr(book.str("plotTitle")), + "sub": book.str("plotSub"), + "items": book.rawArray("plot"), + "meanings": book.rawArray("plotMeaning"), + ]) + DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(book.str("plotTitle"))) { + vm.markBookPlot(id) + } + } + } +} + diff --git a/ios/Maturita/UI/Icons.swift b/ios/Maturita/UI/Icons.swift new file mode 100644 index 0000000..9874349 --- /dev/null +++ b/ios/Maturita/UI/Icons.swift @@ -0,0 +1,304 @@ +import SwiftUI + +struct BackIcon: View { + var color: Color + var size: CGFloat = 16 + var body: some View { + Canvas { ctx, sz in + var p = Path() + p.move(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.18)) + p.addLine(to: CGPoint(x: sz.width * 0.28, y: sz.height * 0.5)) + p.addLine(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.82)) + ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round)) + } + .frame(width: size, height: size) + } +} + +struct SearchIcon: View { + var color: Color + var size: CGFloat = 18 + var body: some View { + Canvas { ctx, sz in + let r = min(sz.width, sz.height) * 0.28 + let c = CGPoint(x: sz.width * 0.42, y: sz.height * 0.42) + ctx.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.12) + var handle = Path() + handle.move(to: CGPoint(x: sz.width * 0.62, y: sz.height * 0.62)) + handle.addLine(to: CGPoint(x: sz.width * 0.82, y: sz.height * 0.82)) + ctx.stroke(handle, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.12, lineCap: .round)) + } + .frame(width: size, height: size) + } +} + +struct StatsIcon: View { + var color: Color + var size: CGFloat = 18 + var body: some View { + Canvas { ctx, sz in + let w = min(sz.width, sz.height) * 0.16 + let bars: [(CGFloat, CGFloat, CGFloat)] = [ + (0.18, 0.48, 0.36), (0.42, 0.22, 0.62), (0.66, 0.36, 0.48), + ] + for b in bars { + let rect = CGRect(x: sz.width * b.0, y: sz.height * b.1, width: w, height: sz.height * b.2) + ctx.fill(Path(roundedRect: rect, cornerRadius: w), with: .color(color)) + } + } + .frame(width: size, height: size) + } +} + +struct SettingsIcon: View { + var color: Color + var size: CGFloat = 18 + var body: some View { + Canvas { ctx, sz in + let cx = sz.width / 2 + let cy = sz.height / 2 + let s = min(sz.width, sz.height) + let rOuter = s * 0.48 + let rInner = s * 0.34 + let rHole = s * 0.16 + let teeth = 8 + let toothHalf = CGFloat.pi / CGFloat(teeth) + var gear = Path() + for i in 0.. some View { + switch name { + case "globe": GlobeIcon(color: color) + case "people": PeopleIcon(color: color) + case "bulb": BulbIcon(color: color) + case "quiz": QuizIcon(color: color) + case "order": OrderIcon(color: color) + default: BookIcon(color: color) + } +} diff --git a/ios/Maturita/UI/PathMap.swift b/ios/Maturita/UI/PathMap.swift new file mode 100644 index 0000000..d7796d1 --- /dev/null +++ b/ios/Maturita/UI/PathMap.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct MapNode { + let id: String + let label: String + let locked: Bool + let done: Bool + let current: Bool + var finish: Bool = false + var onClick: (() -> Void)? +} + +struct PathMap: View { + let nodes: [MapNode] + let palette: Palette + + var body: some View { + ScrollView { + LazyVStack(spacing: 10) { + ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in + Button { + if !node.locked { node.onClick?() } + } label: { + CardBox(palette: palette) { + HStack(spacing: 12) { + let bg: Color = { + if node.locked { return palette.lockedBg } + if node.current || node.done { return palette.accent } + return palette.node + }() + let fg: Color = { + if node.locked { return palette.overlay } + if node.current || node.done { return palette.onAccent } + return palette.text + }() + ZStack { + Circle().fill(bg).frame(width: 44, height: 44) + if node.locked { + LockIcon(color: palette.overlay, size: 16) + } else if node.done { + CheckIcon(color: palette.onAccent, size: 16) + } else { + Text("\(i + 1)") + .font(.system(size: 16, weight: .heavy)) + .foregroundStyle(fg) + } + } + Text(node.label) + .font(.system(size: 16, weight: node.current ? .bold : .semibold)) + .foregroundStyle(node.locked ? palette.overlay : palette.text) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .buttonStyle(.plain) + .disabled(node.locked || node.onClick == nil) + .opacity(node.locked ? 0.48 : 1) + } + } + .padding(.bottom, 24) + } + } +} diff --git a/ios/Maturita/UI/Screens.swift b/ios/Maturita/UI/Screens.swift new file mode 100644 index 0000000..419a36b --- /dev/null +++ b/ios/Maturita/UI/Screens.swift @@ -0,0 +1,698 @@ +import SwiftUI + +struct WelcomeScreen: View { + @ObservedObject var vm: AppModel + + var body: some View { + let p = vm.palette + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text("maturita.c") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(p.text) + Text("sestavení \(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))") + .font(.system(size: 12)) + .foregroundStyle(p.overlay) + .padding(.bottom, 12) + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text(vm.tr("welcome_title")) + .font(.system(size: 32, weight: .medium)) + .foregroundStyle(p.text) + Text(vm.tr("welcome_body_ios")) + .font(.system(size: 16)) + .foregroundStyle(p.subtext) + PrimaryButton(label: vm.tr("continue"), palette: p) { vm.go(.subjects) } + } + Spacer(minLength: 0) + VStack { + Text(vm.tr("welcome_stat_value")) + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(p.text) + Text(vm.tr("welcome_stat_label")) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(p.subtext) + .multilineTextAlignment(.center) + } + .frame(width: 108, height: 108) + .background(p.mantle, in: Circle()) + .overlay(Circle().stroke(p.text.opacity(0.08), lineWidth: 1)) + } + FeatureCard(vm: vm, t: "welcome_feat1_title", b: "welcome_feat1_body", l: "welcome_feat1_link") + .padding(.top, 28) + FeatureCard(vm: vm, t: "welcome_feat2_title", b: "welcome_feat2_body", l: "welcome_feat2_link") + .padding(.top, 12) + } + .padding(.horizontal, 24) + .padding(.vertical, 16) + } + } +} + +private struct FeatureCard: View { + @ObservedObject var vm: AppModel + let t, b, l: String + + var body: some View { + let p = vm.palette + CardBox(palette: p) { + VStack(alignment: .leading, spacing: 6) { + Text(vm.tr(t)).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text) + Text(vm.tr(b)).font(.system(size: 15)).foregroundStyle(p.subtext) + PillButton(label: vm.tr(l), palette: p) { vm.go(.subjects) } + .padding(.top, 4) + } + } + } +} + +struct SubjectsScreen: View { + @ObservedObject var vm: AppModel + + var body: some View { + let p = vm.palette + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: vm.tr("subjects_title"), subtitle: vm.tr("subjects_sub")) + .padding(.horizontal, 16) + ScrollView { + VStack(spacing: 12) { + ForEach(Array(stride(from: 0, to: vm.content.subjects.count, by: 2)), id: \.self) { i in + HStack(spacing: 12) { + subjectTile(vm.content.subjects[i], p) + if i + 1 < vm.content.subjects.count { + subjectTile(vm.content.subjects[i + 1], p) + } else { + Color.clear.frame(maxWidth: .infinity).frame(height: 132) + } + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + } + } + + private func subjectTile(_ s: J, _ p: Palette) -> some View { + let open = s.bool("open") + return Button { + if open { vm.goPage(s.str("target")) } + } label: { + VStack(spacing: 10) { + switch s.str("icon") { + case "de": GermanFlag(size: 44) + case "wifi": WifiIcon(color: p.text, size: 36) + case "chip": ChipIcon(color: p.text, size: 36) + case "cz": CzechFlag(size: 44) + default: LockIcon(color: p.overlay, size: 22) + } + Text(vm.tr(s.str("key"))) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(p.text) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .frame(height: 132) + .background(p.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(p.text.opacity(open ? 0.08 : 0.04), lineWidth: 1) + ) + .opacity(open ? 1 : 0.48) + } + .buttonStyle(.plain) + .disabled(!open) + } +} + +struct SettingsSheet: View { + @ObservedObject var vm: AppModel + + var body: some View { + let p = vm.palette + ScrollView { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(vm.tr("settings_title")) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(p.text) + Spacer() + PillButton(label: vm.tr("back"), palette: p) { vm.settingsOpen = false } + } + Text(vm.tr("mode")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8) + HStack(spacing: 4) { + SegChip(label: vm.tr("mode_dark"), selected: vm.mode == .dark, palette: p) { vm.applyMode(.dark) } + SegChip(label: vm.tr("mode_light"), selected: vm.mode == .light, palette: p) { vm.applyMode(.light) } + } + .padding(3) + .background(p.surface1, in: Capsule()) + Text(vm.tr("theme")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8) + ForEach(Array(themeNames.enumerated()), id: \.offset) { i, name in + if let id = ThemeId(rawValue: i) { + let sw = themePalette(id, vm.mode) + let sel = vm.themeId == id + Button { vm.setTheme(id) } label: { + HStack(spacing: 10) { + ZStack { + Circle().fill(sw.base) + Circle().fill(sw.accent).frame(width: 10, height: 10).offset(x: -8) + Circle().fill(sw.accent3).frame(width: 10, height: 10) + Circle().fill(sw.success).frame(width: 10, height: 10).offset(x: 8) + if sel { Circle().stroke(p.accent, lineWidth: 2.2) } + } + .frame(width: 36, height: 36) + Text(name).font(.system(size: 16, weight: .medium)).foregroundStyle(p.text) + Spacer() + } + .padding(10) + .background(sel ? p.surface1 : p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + } + } + Text(vm.tr("language")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8) + HStack(spacing: 4) { + SegChip(label: "Čeština", selected: vm.lang == .cs, palette: p) { vm.applyLang(.cs) } + SegChip(label: "English", selected: vm.lang == .en, palette: p) { vm.applyLang(.en) } + } + .padding(3) + .background(p.surface1, in: Capsule()) + Text(vm.tr("updates")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8) + Text(updateText) + .font(.system(size: 14)) + .foregroundStyle(p.subtext) + HStack(spacing: 8) { + PillButton(label: vm.tr("update_check"), palette: p) { vm.checkUpdate(true) } + if vm.update.canInstall { + PrimaryButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() } + } + } + .padding(.bottom, 24) + } + .padding(20) + } + .background(p.base) + } + + private var updateText: String { + if let arg = vm.update.messageArg { + return vm.fmt(vm.update.messageKey, arg) + } + return vm.tr(vm.update.messageKey) + } +} + +struct StatsScreen: View { + @ObservedObject var vm: AppModel + + var body: some View { + let p = vm.palette + let sum = summarize(vm.content, vm.progress) + let pct = sum.totalEx == 0 ? 0 : (100 * sum.doneEx / sum.totalEx) + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: vm.tr("stats_title"), subtitle: vm.tr("stats_sub")) + .padding(.horizontal, 16) + ScrollView { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + metric(p, vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx)) + metric(p, vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", pct)) + metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits)) + } + ProgressBar(fraction: CGFloat(pct) / 100, palette: p, height: 8) + Text(vm.tr("stats_section")) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(p.subtext) + .padding(.top, 10) + ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in + let open = s.bool("open") + let part = subjectSum(vm, s) + CardBox(palette: p) { + VStack(alignment: .leading, spacing: 4) { + Text(vm.tr(s.str("key"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) + Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked")) + .font(.system(size: 13)).foregroundStyle(p.subtext) + if open && part.totalEx > 0 { + ProgressBar(fraction: CGFloat(part.doneEx) / CGFloat(part.totalEx), palette: p, height: 6) + .padding(.top, 4) + } + } + } + .opacity(open ? 1 : 0.55) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + } + } + + private func metric(_ p: Palette, _ label: String, _ value: String) -> some View { + CardBox(palette: p) { + VStack(alignment: .leading) { + Text(label).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext) + Text(value).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text) + } + } + } +} + +struct ProgressBar: View { + let fraction: CGFloat + let palette: Palette + var height: CGFloat = 8 + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(palette.surface1) + Capsule().fill(palette.accent).frame(width: geo.size.width * min(max(fraction, 0), 1)) + } + } + .frame(height: height) + } +} + +private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum { + var sum = ProgressSum() + switch s.str("target") { + case "roadmap": + for u in vm.content.german where u.bool("unlocked") { + let n = u.strs("names").count + sum.totalEx += n + sum.doneEx += (1...n).filter { vm.progress.germanDone(u.int("id"), $0) }.count + } + case "netyears": + sum.totalEx = vm.content.netLessons.count + sum.doneEx = vm.content.netLessons.filter { vm.progress.netDone($0.int("id")) }.count + case "hwmap": + sum.totalEx = vm.content.hw.count + sum.doneEx = vm.content.hw.filter { vm.progress.hwDone($0.int("id")) }.count + case "czechmap": + sum.totalEx = vm.content.mluvnice.count + sum.doneEx = vm.content.mluvnice.filter { vm.progress.mluvDone($0.int("id")) }.count + default: + break + } + return sum +} + +struct SearchOverlay: View { + @ObservedObject var vm: AppModel + + var body: some View { + let p = vm.palette + let q = normalizeAnswer(vm.searchQuery) + let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }.prefix(24) + VStack(alignment: .leading, spacing: 12) { + HStack { + ZStack(alignment: .leading) { + if vm.searchQuery.isEmpty { + Text(vm.tr("search_placeholder")).foregroundStyle(p.overlay) + } + TextField("", text: $vm.searchQuery) + .foregroundStyle(p.text) + .tint(p.accent) + } + .padding(14) + .background(p.mantle, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + PillButton(label: vm.tr("back"), palette: p) { vm.searchOpen = false } + } + if hits.isEmpty { + Text(q.isEmpty ? vm.tr("search_hint") : vm.tr("search_empty")).foregroundStyle(p.subtext) + } else { + ScrollView { + VStack(spacing: 8) { + ForEach(Array(hits), id: \.target) { hit in + Button { + if !hit.locked { + vm.goPage(hit.target) + vm.searchOpen = false + } + } label: { + CardBox(palette: p) { + VStack(alignment: .leading) { + Text(hit.title).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text) + Text(hit.sub).font(.system(size: 13)).foregroundStyle(p.subtext) + } + } + } + .buttonStyle(.plain) + .opacity(hit.locked ? 0.5 : 1) + } + } + } + } + Spacer(minLength: 0) + } + .padding(16) + .background(p.base.opacity(0.96)) + } +} + +private struct Hit: Identifiable { + var id: String { target + title } + let title: String + let sub: String + let target: String + let locked: Bool + let hay: String +} + +private func buildSearch(_ vm: AppModel) -> [Hit] { + var out: [Hit] = [] + func add(_ title: String, _ sub: String, _ target: String, locked: Bool = false, extra: String = "") { + out.append(Hit(title: title, sub: sub, target: target, locked: locked, hay: "\(title) \(sub) \(target) \(extra)")) + } + add(vm.tr("subjects_title"), vm.tr("search_page"), "subjects", extra: "predmety") + add(vm.tr("stats"), vm.tr("search_page"), "stats") + add(vm.tr("search_home"), vm.tr("search_page"), "welcome") + for s in vm.content.subjects { + add(vm.tr(s.str("key")), vm.tr("search_subject"), s.strOrNull("target") ?? "subjects", locked: !s.bool("open")) + } + for u in vm.content.german { + let unlocked = u.bool("unlocked") + add(u.str("title"), vm.tr("search_unit"), u.strOrNull("page") ?? "roadmap", locked: !unlocked, extra: "deutsch") + if unlocked { + for (i, name) in u.strs("names").enumerated() { + add(name, u.str("title"), "u\(u.int("id") + 1)e\(i + 1)") + } + if u.has("branch") { add("Vokabeltraining", u.str("title"), u.str("branch"), extra: "vokabel") } + } + } + for l in vm.content.netLessons { + add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "netunit\(l.int("id"))", extra: "site") + add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "netex\(l.int("id"))") + } + for l in vm.content.hw { + add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "hwunit\(l.int("id"))", extra: "hardware") + add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "hwex\(l.int("id"))") + } + add(vm.tr("Mluvnice"), vm.tr("search_lesson"), "mluvnice") + add(vm.tr("Maturitní četba"), vm.tr("search_book"), "readinglist") + for b in vm.content.books { + add(b.str("title"), vm.tr("search_book"), b.str("page")) + } + return out +} + +struct RoadmapScreen: View { + @ObservedObject var vm: AppModel + + var body: some View { + let current = vm.content.german.firstIndex { u in + u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) } + } + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: vm.tr("roadmap_title"), subtitle: vm.tr("roadmap_sub")) + .padding(.horizontal, 12) + PathMap( + nodes: vm.content.german.enumerated().map { i, u in + let unlocked = u.bool("unlocked") + let names = u.strs("names") + let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) } + return MapNode( + id: u.str("page"), + label: u.str("title"), + locked: !unlocked, + done: done, + current: i == current, + finish: i == vm.content.german.count - 1, + onClick: unlocked ? { vm.go(.unitMap(u.int("id"))) } : nil + ) + }, + palette: vm.palette + ) + .padding(.horizontal, 12) + } + } +} + +struct UnitMapScreen: View { + @ObservedObject var vm: AppModel + let unitId: Int + + var body: some View { + if let u = vm.content.germanUnit(unitId) { + let names = u.strs("names") + let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) } + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: u.str("title"), subtitle: vm.tr(u.str("sub"))) + .padding(.horizontal, 12) + PathMap( + nodes: names.enumerated().map { i, name in + MapNode( + id: "e\(i + 1)", + label: name, + locked: false, + done: vm.progress.germanDone(unitId, i + 1), + current: i == next, + onClick: { vm.go(.germanEx(unitId, i + 1)) } + ) + }, + palette: vm.palette + ) + .padding(.horizontal, 12) + if u.has("branch") { + PrimaryButton(label: vm.tr("Vokabeltraining"), palette: vm.palette) { vm.go(.vocab(unitId)) } + .padding(.bottom, 16) + } + } + } + } +} + +struct NetYearsScreen: View { + @ObservedObject var vm: AppModel + + var body: some View { + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_years_title"), subtitle: vm.tr("net_years_sub")) + .padding(.horizontal, 16) + ScrollView { + VStack(spacing: 12) { + yearCard(vm.tr("net_year1"), vm.tr("net_sub"), true) { vm.go(.netMap) } + ForEach(["net_year2", "net_year3", "net_year4"], id: \.self) { key in + yearCard(vm.tr(key), vm.tr("net_year_locked_sub"), false) {} + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + } + + private func yearCard(_ title: String, _ sub: String, _ open: Bool, _ action: @escaping () -> Void) -> some View { + let p = vm.palette + return Button(action: action) { + CardBox(palette: p) { + HStack { + VStack(alignment: .leading) { + Text(title).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text) + Text(sub).font(.system(size: 14)).foregroundStyle(p.subtext) + } + Spacer() + if !open { LockIcon(color: p.overlay) } + } + } + } + .buttonStyle(.plain) + .disabled(!open) + .opacity(open ? 1 : 0.48) + } +} + +struct LessonMapScreen: View { + @ObservedObject var vm: AppModel + let title: String + let sub: String + let nodes: [MapNode] + + var body: some View { + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) + .padding(.horizontal, 12) + PathMap(nodes: nodes, palette: vm.palette) + .padding(.horizontal, 12) + } + } +} + +struct SlidesScreen: View { + @ObservedObject var vm: AppModel + let title: String + let sub: String + let slides: [J] + var onExercise: () -> Void + @State private var idx = 0 + + var body: some View { + let p = vm.palette + if let slide = slides.indices.contains(idx) ? slides[idx] : nil { + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) + .padding(.horizontal, 16) + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text(slide.str("kicker")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext) + Text(slide.str("title")).font(.system(size: 24, weight: .bold)).foregroundStyle(p.text) + if let tip = slide.strOrNull("tip") { + CardBox(palette: p) { Text(tip).foregroundStyle(p.text) } + } + ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in + CardBox(palette: p) { Text(line).font(.system(size: 16)).foregroundStyle(p.text) } + } + } + .padding(.horizontal, 16) + } + HStack { + if idx > 0 { + PillButton(label: vm.tr("net_slide_prev"), palette: p) { idx -= 1 } + } + Spacer() + if idx < slides.count - 1 { + PrimaryButton(label: vm.tr("net_slide_next"), palette: p) { idx += 1 } + } else { + PrimaryButton(label: vm.tr("net_slide_start"), palette: p, action: onExercise) + } + } + .padding(12) + } + } + } +} + +struct CzechMapScreen: View { + @ObservedObject var vm: AppModel + + var body: some View { + let p = vm.palette + let items: [(String, String, Route?)] = [ + ("L", vm.tr("Literatura"), nil), + ("M", vm.tr("Mluvnice"), .mluvnice), + ("Č", vm.tr("Maturitní četba"), .readingList), + ] + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Český jazyk a literatura"), subtitle: vm.tr("czech_sub")) + .padding(.horizontal, 16) + ScrollView { + VStack(spacing: 12) { + ForEach(items, id: \.0) { letter, name, route in + Button { + if let route { vm.go(route) } + } label: { + CardBox(palette: p) { + HStack(spacing: 14) { + ZStack { + Circle().fill(route == nil ? p.lockedBg : p.accent).frame(width: 56, height: 56) + if route == nil { + LockIcon(color: p.overlay) + } else { + Text(letter).font(.system(size: 20, weight: .heavy)).foregroundStyle(p.onAccent) + } + } + Text(name).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text) + Spacer() + } + } + } + .buttonStyle(.plain) + .disabled(route == nil) + .opacity(route == nil ? 0.48 : 1) + } + } + .padding(.horizontal, 16) + } + } + } +} + +struct BookListScreen: View { + @ObservedObject var vm: AppModel + + var body: some View { + let p = vm.palette + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub")) + .padding(.horizontal, 16) + ScrollView { + VStack(spacing: 12) { + ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in + Button { vm.go(.book(b.str("id"))) } label: { + CardBox(palette: p) { + HStack(spacing: 12) { + BookIcon(color: p.text, size: 28) + VStack(alignment: .leading) { + Text(b.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) + Text(vm.tr(b.str("subKey"))).font(.system(size: 13)).foregroundStyle(p.subtext) + } + Spacer() + } + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + } + } + } +} + +struct BookScreen: View { + @ObservedObject var vm: AppModel + let id: String + + var body: some View { + if let b = vm.content.book(id) { + let p = vm.palette + VStack(spacing: 0) { + PageTop(vm: vm, back: { vm.back() }, title: b.str("title"), subtitle: vm.tr(b.str("subKey"))) + .padding(.horizontal, 16) + ScrollView { + VStack(spacing: 12) { + Button { vm.go(.bookQuiz(id)) } label: { + CardBox(palette: p) { + HStack(spacing: 10) { + QuizIcon(color: p.text) + VStack(alignment: .leading) { + Text(vm.tr(b.str("quizTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) + Text(vm.tr(b.str("quizSub"))).font(.system(size: 13)).foregroundStyle(p.subtext) + } + } + } + } + .buttonStyle(.plain) + Button { vm.go(.bookPlot(id)) } label: { + CardBox(palette: p) { + HStack(spacing: 10) { + OrderIcon(color: p.text) + VStack(alignment: .leading) { + Text(vm.tr(b.str("plotTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) + Text(vm.tr(b.str("plotSub"))).font(.system(size: 13)).foregroundStyle(p.subtext) + } + } + } + } + .buttonStyle(.plain) + ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in + CardBox(palette: p) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + noteIcon(note.str("icon"), p.text) + Text(note.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) + } + ForEach(note.strs("lines"), id: \.self) { line in + Text("• \(line)").font(.system(size: 15)).foregroundStyle(p.text) + } + } + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 20) + } + } + } + } +} diff --git a/ios/Maturita/Update/Updater.swift b/ios/Maturita/Update/Updater.swift new file mode 100644 index 0000000..8c6949a --- /dev/null +++ b/ios/Maturita/Update/Updater.swift @@ -0,0 +1,113 @@ +import Foundation +import UIKit + +struct UpdateState { + var status = "idle" + var remote: String? + var messageKey = "update_current" + var messageArg: String? = String(AppConfig.commit.prefix(7)) + var canInstall = false +} + +final class Updater { + private let repo = AppConfig.updateRepo + private let branch = AppConfig.updateBranch + + func check(interactive: Bool, cb: @escaping (UpdateState) -> Void) { + if AppConfig.commit == "dev" { + emit(cb, UpdateState(status: "dev", messageKey: "update_err_devbuild", messageArg: nil)) + return + } + emit(cb, UpdateState(status: "checking", messageKey: "update_checking", messageArg: nil)) + DispatchQueue.global(qos: .utility).async { + do { + let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom") + let remote: String + if let sha = self.atomMainSha(atom) { + remote = sha + } else if let tip = self.atomTip(atom) { + remote = try self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION").trimmingCharacters(in: .whitespacesAndNewlines) + } else { + throw URLError(.cannotParseResponse) + } + let local = AppConfig.commit + if self.sameCommit(remote, local) { + self.emit(cb, UpdateState(status: "ok", remote: remote, messageKey: "update_uptodate", messageArg: String(remote.prefix(7)))) + } else { + self.emit(cb, UpdateState(status: "available", remote: remote, messageKey: "update_available", messageArg: String(remote.prefix(7)), canInstall: true)) + } + } catch { + self.emit(cb, UpdateState(status: "error", messageKey: "update_err_network", messageArg: nil)) + } + } + } + + func install(cb: @escaping (UpdateState) -> Void) { + emit(cb, UpdateState(status: "downloading", messageKey: "update_downloading", messageArg: nil)) + DispatchQueue.global(qos: .utility).async { + do { + let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom") + let tip = self.atomTip(atom) ?? "" + let page = "https://github.com/\(self.repo)/raw/main/installers/maturita-ios.html" + DispatchQueue.main.async { + if let url = URL(string: page) { + UIApplication.shared.open(url) + } + } + self.emit(cb, UpdateState(status: "staged", remote: tip, messageKey: "update_ios_sideload", messageArg: String(tip.prefix(7)))) + } catch { + self.emit(cb, UpdateState(status: "error", messageKey: "update_err_download", messageArg: nil)) + } + } + } + + private func emit(_ cb: @escaping (UpdateState) -> Void, _ state: UpdateState) { + DispatchQueue.main.async { cb(state) } + } + + private func atomTip(_ atom: String) -> String? { + if let m = atom.range(of: #"Commit/([0-9a-f]{40})"#, options: [.regularExpression, .caseInsensitive]) { + let s = String(atom[m]) + return s.split(separator: "/").last.map(String.init) + } + if let m = atom.range(of: #"/commit/([0-9a-f]{7,40})"#, options: [.regularExpression, .caseInsensitive]) { + return String(atom[m]).split(separator: "/").last.map(String.init) + } + return nil + } + + private func atomMainSha(_ atom: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: #"Build from ([0-9a-f]{7,40})"#, options: .caseInsensitive), + let match = regex.firstMatch(in: atom, range: NSRange(atom.startIndex..., in: atom)), + let range = Range(match.range(at: 1), in: atom) + else { return nil } + return String(atom[range]) + } + + private func sameCommit(_ a: String, _ b: String) -> Bool { + let n = min(a.count, b.count) + return n >= 7 && a.prefix(n).lowercased() == b.prefix(n).lowercased() + } + + private func get(_ url: String) throws -> String { + guard let u = URL(string: url) else { throw URLError(.badURL) } + var req = URLRequest(url: u, timeoutInterval: 15) + req.setValue("maturita.c-ios", forHTTPHeaderField: "User-Agent") + req.setValue("*/*", forHTTPHeaderField: "Accept") + let sem = DispatchSemaphore(value: 0) + var result: Result = .failure(URLError(.unknown)) + URLSession.shared.dataTask(with: req) { data, resp, err in + if let err { result = .failure(err) } + else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + result = .failure(URLError(.badServerResponse)) + } else if let data, let text = String(data: data, encoding: .utf8) { + result = .success(text) + } else { + result = .failure(URLError(.cannotDecodeContentData)) + } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 20) + return try result.get() + } +} diff --git a/scripts/bundle-ios.sh b/scripts/bundle-ios.sh new file mode 100755 index 0000000..7204b55 --- /dev/null +++ b/scripts/bundle-ios.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Build an unsigned iOS IPA for sideloading (Xcode / AltStore / Sideloadly). + +set -eu + +root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +cd "$root" + +python3 scripts/extract-android-content.py + +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +COMMIT="${COMMIT:-$(git -C "$root" rev-parse HEAD 2>/dev/null || echo dev)}" +dd="$root/ios/DerivedData" + +rm -rf "$root/ios/Payload" +mkdir -p "$dd" "$root/dist-ios" + +xcodebuild \ + -project ios/Maturita.xcodeproj \ + -scheme Maturita \ + -configuration Release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "$dd" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + COMMIT="$COMMIT" + +app=$(find "$dd" -name 'Maturita.app' -path '*/Release-iphoneos/*' | head -n 1) +if [ -z "$app" ]; then + echo "Maturita.app not found" >&2 + exit 1 +fi + +mkdir -p "$root/ios/Payload" +cp -R "$app" "$root/ios/Payload/Maturita.app" +(cd "$root/ios" && zip -r -q "$root/dist-ios/maturita-ios.ipa" Payload) +echo "wrote $root/dist-ios/maturita-ios.ipa" diff --git a/scripts/extract-android-content.py b/scripts/extract-android-content.py index 901b040..ceb2581 100644 --- a/scripts/extract-android-content.py +++ b/scripts/extract-android-content.py @@ -10,7 +10,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" -OUT = ROOT / "android" / "app" / "src" / "main" / "assets" / "content.json" +OUTS = [ + ROOT / "android" / "app" / "src" / "main" / "assets" / "content.json", + ROOT / "ios" / "Maturita" / "content.json", +] +OUT = OUTS[0] IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") @@ -1223,8 +1227,10 @@ def main() -> int: else: german_units[2]["exercises"]["5"]["bank"] = raw[0] - OUT.parent.mkdir(parents=True, exist_ok=True) - OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + text = json.dumps(payload, ensure_ascii=False, indent=2) + for dest in OUTS: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(text, encoding="utf-8") def count(path, pred): node = payload @@ -1232,7 +1238,7 @@ def main() -> int: node = node[p] return sum(1 for x in node if pred(x)) - print(f"wrote {OUT}") + print("wrote " + ", ".join(str(dest) for dest in OUTS)) print(f" i18n keys: {len(i18n)}") print(f" german unlocked exercises: " f"{sum(len(u.get('exercises', {})) for u in german_units if u['unlocked'])}") diff --git a/src/i18n.c b/src/i18n.c index 3a69e7c..e2a160e 100644 --- a/src/i18n.c +++ b/src/i18n.c @@ -61,6 +61,14 @@ const TrEntry tr_ui[] = { "stejný obsah jako na počítači.", "Exam practice on your phone. Czech and English, " "the same content as on the computer."}, + {"welcome_body_ios", + "Procvičování k maturitě v iPhonu. Česky i anglicky, " + "stejný obsah jako na počítači.", + "Exam practice on iPhone. Czech and English, " + "the same content as on the computer."}, + {"update_ios_sideload", + "Nový build je na GitHubu. Nainstalujte ho znovu přes Xcode nebo AltStore (%s).", + "A newer build is on GitHub. Reinstall it with Xcode or AltStore (%s)."}, {"welcome_feat1_title", "Efektivní procvičování", "Focused practice"}, {"welcome_feat1_body", "Cvičení, nápovědy a vokabeltraining. Aplikace si pamatuje, kde jste skončili.",