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

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

View file

@ -0,0 +1,370 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
A101000000000000000001 /* MaturitaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000001 /* MaturitaApp.swift */; };
A101000000000000000002 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000002 /* AppModel.swift */; };
A101000000000000000003 /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000003 /* Route.swift */; };
A101000000000000000004 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000004 /* JSON.swift */; };
A101000000000000000005 /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000005 /* Content.swift */; };
A101000000000000000006 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000006 /* Theme.swift */; };
A101000000000000000007 /* Answers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000007 /* Answers.swift */; };
A101000000000000000008 /* ProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000008 /* ProgressStore.swift */; };
A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; };
A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; };
A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.swift */; };
A101000000000000000012 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000012 /* PathMap.swift */; };
A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; };
A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; };
A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.swift */; };
A101000000000000000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A201000000000000000016 /* Assets.xcassets */; };
A101000000000000000017 /* content.json in Resources */ = {isa = PBXBuildFile; fileRef = A201000000000000000017 /* content.json */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
A201000000000000000001 /* MaturitaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaturitaApp.swift; sourceTree = "<group>"; };
A201000000000000000002 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
A201000000000000000003 /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route.swift; sourceTree = "<group>"; };
A201000000000000000004 /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = "<group>"; };
A201000000000000000005 /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = "<group>"; };
A201000000000000000006 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
A201000000000000000007 /* Answers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Answers.swift; sourceTree = "<group>"; };
A201000000000000000008 /* ProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressStore.swift; sourceTree = "<group>"; };
A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = "<group>"; };
A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = "<group>"; };
A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = "<group>"; };
A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = "<group>"; };
A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = "<group>"; };
A201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A201000000000000000017 /* content.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = content.json; sourceTree = "<group>"; };
A201000000000000000018 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A301000000000000000001 /* Maturita.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Maturita.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A401000000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A501000000000000000001 = {
isa = PBXGroup;
children = (
A501000000000000000002 /* Maturita */,
A501000000000000000006 /* Products */,
);
sourceTree = "<group>";
};
A501000000000000000002 /* Maturita */ = {
isa = PBXGroup;
children = (
A201000000000000000001 /* MaturitaApp.swift */,
A201000000000000000002 /* AppModel.swift */,
A201000000000000000003 /* Route.swift */,
A501000000000000000003 /* Data */,
A501000000000000000004 /* Update */,
A501000000000000000005 /* UI */,
A201000000000000000016 /* Assets.xcassets */,
A201000000000000000017 /* content.json */,
A201000000000000000018 /* Info.plist */,
);
path = Maturita;
sourceTree = "<group>";
};
A501000000000000000003 /* Data */ = {
isa = PBXGroup;
children = (
A201000000000000000004 /* JSON.swift */,
A201000000000000000005 /* Content.swift */,
A201000000000000000006 /* Theme.swift */,
A201000000000000000007 /* Answers.swift */,
A201000000000000000008 /* ProgressStore.swift */,
);
path = Data;
sourceTree = "<group>";
};
A501000000000000000004 /* Update */ = {
isa = PBXGroup;
children = (
A201000000000000000009 /* Updater.swift */,
);
path = Update;
sourceTree = "<group>";
};
A501000000000000000005 /* UI */ = {
isa = PBXGroup;
children = (
A201000000000000000010 /* Components.swift */,
A201000000000000000011 /* Icons.swift */,
A201000000000000000012 /* PathMap.swift */,
A201000000000000000013 /* Screens.swift */,
A201000000000000000014 /* Exercises.swift */,
A201000000000000000015 /* ExercisesMore.swift */,
);
path = UI;
sourceTree = "<group>";
};
A501000000000000000006 /* Products */ = {
isa = PBXGroup;
children = (
A301000000000000000001 /* Maturita.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A601000000000000000001 /* Maturita */ = {
isa = PBXNativeTarget;
buildConfigurationList = A801000000000000000002 /* Build configuration list for PBXNativeTarget "Maturita" */;
buildPhases = (
A701000000000000000004 /* Extract content */,
A701000000000000000001 /* Sources */,
A401000000000000000001 /* Frameworks */,
A701000000000000000002 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Maturita;
productName = Maturita;
productReference = A301000000000000000001 /* Maturita.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A001000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1600;
TargetAttributes = {
A601000000000000000001 = {
CreatedOnToolsVersion = 16.0;
};
};
};
buildConfigurationList = A801000000000000000001 /* Build configuration list for PBXProject "Maturita" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = cs;
hasScannedForEncodings = 0;
knownRegions = (
en,
cs,
Base,
);
mainGroup = A501000000000000000001;
productRefGroup = A501000000000000000006 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A601000000000000000001 /* Maturita */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A701000000000000000002 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A101000000000000000016 /* Assets.xcassets in Resources */,
A101000000000000000017 /* content.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
A701000000000000000004 /* Extract content */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Extract content";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Maturita/content.json",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "python3 \"${SRCROOT}/../scripts/extract-android-content.py\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A701000000000000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A101000000000000000001 /* MaturitaApp.swift in Sources */,
A101000000000000000002 /* AppModel.swift in Sources */,
A101000000000000000003 /* Route.swift in Sources */,
A101000000000000000004 /* JSON.swift in Sources */,
A101000000000000000005 /* Content.swift in Sources */,
A101000000000000000006 /* Theme.swift in Sources */,
A101000000000000000007 /* Answers.swift in Sources */,
A101000000000000000008 /* ProgressStore.swift in Sources */,
A101000000000000000009 /* Updater.swift in Sources */,
A101000000000000000010 /* Components.swift in Sources */,
A101000000000000000011 /* Icons.swift in Sources */,
A101000000000000000012 /* PathMap.swift in Sources */,
A101000000000000000013 /* Screens.swift in Sources */,
A101000000000000000014 /* Exercises.swift in Sources */,
A101000000000000000015 /* ExercisesMore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A901000000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_TESTABILITY = YES;
GCC_DYNAMIC_NO_PIC = NO;
GCC_OPTIMIZATION_LEVEL = 0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
A901000000000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A901000000000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
COMMIT = dev;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Maturita/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "maturita.c";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = ios-1;
PRODUCT_BUNDLE_IDENTIFIER = org.maturita.maturita;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A901000000000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
COMMIT = dev;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Maturita/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "maturita.c";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = ios-1;
PRODUCT_BUNDLE_IDENTIFIER = org.maturita.maturita;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A801000000000000000001 /* Build configuration list for PBXProject "Maturita" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A901000000000000000001 /* Debug */,
A901000000000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A801000000000000000002 /* Build configuration list for PBXNativeTarget "Maturita" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A901000000000000000003 /* Debug */,
A901000000000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A001000000000000000001 /* Project object */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A601000000000000000001"
BuildableName = "Maturita.app"
BlueprintName = "Maturita"
ReferencedContainer = "container:Maturita.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeFlags = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A601000000000000000001"
BuildableName = "Maturita.app"
BlueprintName = "Maturita"
ReferencedContainer = "container:Maturita.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeFlags = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A601000000000000000001"
BuildableName = "Maturita.app"
BlueprintName = "Maturita"
ReferencedContainer = "container:Maturita.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

113
ios/Maturita/AppModel.swift Normal file
View file

@ -0,0 +1,113 @@
import Foundation
import SwiftUI
final class AppModel: ObservableObject {
let content = Content.load()
let progress = ProgressStore()
let updater = Updater()
@Published var themeId: ThemeId
@Published var mode: ColorMode
@Published var lang: UiLang
@Published var settingsOpen = false
@Published var searchOpen = false
@Published var searchQuery = ""
@Published var tick = 0
@Published var update = UpdateState()
@Published var stack: [Route] = [.welcome]
init() {
themeId = progress.themeId
mode = progress.mode
lang = progress.lang
}
var route: Route { stack.last ?? .welcome }
var canGoBack: Bool { stack.count > 1 }
var palette: Palette { themePalette(themeId, mode) }
func tr(_ key: String?) -> String { content.tr(key, lang: lang) }
func fmt(_ key: String, _ args: CVarArg...) -> String {
content.fmt(key, lang: lang, args)
}
func go(_ r: Route) {
if stack.last == r { return }
stack.append(r)
searchOpen = false
}
func goPage(_ page: String?) {
guard let page, let r = routeFromPage(page) else { return }
go(r)
}
func back() {
if stack.count > 1 { stack.removeLast() }
}
func setTheme(_ id: ThemeId) {
themeId = id
progress.themeId = id
}
func applyMode(_ m: ColorMode) {
mode = m
progress.mode = m
}
func applyLang(_ l: UiLang) {
lang = l
progress.lang = l
}
func refresh() { tick += 1 }
func markGerman(_ unit: Int, _ ex: Int) {
progress.markGerman(unit, ex)
refresh()
}
func markVocab(_ unit: Int) {
progress.markVocab(unit)
refresh()
}
func markNet(_ id: Int) {
progress.markNet(id)
refresh()
}
func markHw(_ id: Int) {
progress.markHw(id)
refresh()
}
func markMluv(_ n: Int) {
progress.markMluv(n)
refresh()
}
func markBookQuiz(_ id: String) {
progress.markBookQuiz(id)
refresh()
}
func markBookPlot(_ id: String) {
progress.markBookPlot(id)
refresh()
}
func checkUpdate(_ interactive: Bool) {
updater.check(interactive: interactive) { [weak self] state in
self?.update = state
}
}
func installUpdate() {
updater.install { [weak self] state in
self?.update = state
}
}
}

View file

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

24
ios/Maturita/Info.plist Normal file
View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>maturita.c</string>
<key>GITCommit</key>
<string>$(COMMIT)</string>
<key>UPDATE_REPO</key>
<string>pepaskopec-blip/maturita.c</string>
<key>UPDATE_BRANCH</key>
<string>builds</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,154 @@
import SwiftUI
@main
struct MaturitaApp: App {
@StateObject private var vm = AppModel()
var body: some Scene {
WindowGroup {
MaturitaRoot(vm: vm)
}
}
}
struct MaturitaRoot: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
ZStack {
p.base.ignoresSafeArea()
VStack(spacing: 0) {
HStack {
Text("maturita.c")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(p.text)
Spacer()
IconHit(action: { vm.searchOpen = true }) { SearchIcon(color: p.text) }
IconHit(action: { vm.go(.stats) }) { StatsIcon(color: p.text) }
IconHit(action: { vm.settingsOpen = true }) { SettingsIcon(color: p.text) }
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
if vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged" {
HStack {
Text(bannerText)
.font(.system(size: 13))
.foregroundStyle(p.text)
Spacer()
if vm.update.canInstall {
PillButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() }
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(p.surface1, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, 12)
}
routeView
.id(vm.tick)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if vm.searchOpen {
SearchOverlay(vm: vm).ignoresSafeArea(edges: .bottom)
}
if vm.settingsOpen {
SettingsSheet(vm: vm).ignoresSafeArea(edges: .bottom)
}
}
.preferredColorScheme(vm.mode == .dark ? .dark : .light)
.onAppear { vm.checkUpdate(false) }
}
private var bannerText: String {
if let arg = vm.update.messageArg {
return vm.fmt(vm.update.messageKey, arg)
}
return vm.tr(vm.update.messageKey)
}
@ViewBuilder
private var routeView: some View {
switch vm.route {
case .welcome: WelcomeScreen(vm: vm)
case .subjects: SubjectsScreen(vm: vm)
case .stats: StatsScreen(vm: vm)
case .roadmap: RoadmapScreen(vm: vm)
case .unitMap(let id): UnitMapScreen(vm: vm, unitId: id)
case .germanEx(let unit, let ex): GermanExercise(vm: vm, unitId: unit, ex: ex)
case .vocab(let id): VocabExercise(vm: vm, unitId: id)
case .netYears: NetYearsScreen(vm: vm)
case .netMap:
LessonMapScreen(
vm: vm,
title: vm.tr("net_year1"),
sub: vm.tr("net_sub"),
nodes: vm.content.netLessons.map { l in
let id = l.int("id")
return MapNode(
id: "n\(id)",
label: vm.tr(l.str("titleKey")),
locked: false,
done: vm.progress.netDone(id),
current: !vm.progress.netDone(id),
onClick: { vm.go(.netLesson(id)) }
)
}
)
case .netLesson(let id):
if let l = vm.content.netLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), sub: vm.tr(l.str("subKey")), slides: l.arr("slides")) {
vm.go(.netEx(id))
}
}
case .netEx(let id): NetQuizScreen(vm: vm, id: id)
case .hwMap:
LessonMapScreen(
vm: vm,
title: vm.tr("Technické vybavení"),
sub: vm.tr("hw_sub"),
nodes: vm.content.hw.map { l in
let id = l.int("id")
return MapNode(
id: "h\(id)",
label: vm.tr(l.str("titleKey")),
locked: false,
done: vm.progress.hwDone(id),
current: !vm.progress.hwDone(id),
onClick: { vm.go(.hwLesson(id)) }
)
}
)
case .hwLesson(let id):
if let l = vm.content.hwLesson(id) {
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), sub: vm.tr(l.str("subKey")), slides: l.arr("slides")) {
vm.go(.hwEx(id))
}
}
case .hwEx(let id): HwQuizScreen(vm: vm, id: id)
case .czechMap: CzechMapScreen(vm: vm)
case .mluvnice:
LessonMapScreen(
vm: vm,
title: vm.tr("Mluvnice"),
sub: "Cvičení z mluvnice – styl maturita / přijímačky z ČJL",
nodes: vm.content.mluvnice.map { m in
let n = m.int("id")
return MapNode(
id: "m\(n)",
label: m.str("name"),
locked: false,
done: vm.progress.mluvDone(n),
current: !vm.progress.mluvDone(n),
onClick: { vm.go(.mluvEx(n)) }
)
}
)
case .mluvEx(let n): MluvExercise(vm: vm, n: n)
case .readingList: BookListScreen(vm: vm)
case .book(let id): BookScreen(vm: vm, id: id)
case .bookQuiz(let id): LitQuizScreen(vm: vm, id: id)
case .bookPlot(let id): PlotScreen(vm: vm, id: id)
}
}
}

98
ios/Maturita/Route.swift Normal file
View file

@ -0,0 +1,98 @@
import Foundation
enum Route: Equatable {
case welcome
case subjects
case stats
case roadmap
case unitMap(Int)
case germanEx(Int, Int)
case vocab(Int)
case netYears
case netMap
case netLesson(Int)
case netEx(Int)
case hwMap
case hwLesson(Int)
case hwEx(Int)
case czechMap
case mluvnice
case mluvEx(Int)
case readingList
case book(String)
case bookQuiz(String)
case bookPlot(String)
var page: String {
switch self {
case .welcome: return "welcome"
case .subjects: return "subjects"
case .stats: return "stats"
case .roadmap: return "roadmap"
case .unitMap(let unitId): return "unit\(unitId + 1)"
case .germanEx(let unitId, let ex): return "u\(unitId + 1)e\(ex)"
case .vocab(let unitId): return "u\(unitId + 1)vocab"
case .netYears: return "netyears"
case .netMap: return "netmap"
case .netLesson(let id): return "netunit\(id)"
case .netEx(let id): return "netex\(id)"
case .hwMap: return "hwmap"
case .hwLesson(let id): return "hwunit\(id)"
case .hwEx(let id): return "hwex\(id)"
case .czechMap: return "czechmap"
case .mluvnice: return "mluvnice"
case .mluvEx(let n): return "mluve\(n)"
case .readingList: return "readinglist"
case .book(let id): return id == "1984" ? "cetba1984" : "cetbaFuks"
case .bookQuiz(let id): return id == "1984" ? "cetba1984quiz" : "cetbaFuksQuiz"
case .bookPlot(let id): return id == "1984" ? "cetba1984dej" : "cetbaFuksDej"
}
}
}
func routeFromPage(_ page: String) -> Route? {
switch page {
case "welcome": return .welcome
case "subjects": return .subjects
case "stats": return .stats
case "roadmap": return .roadmap
case "netyears": return .netYears
case "netmap": return .netMap
case "hwmap": return .hwMap
case "czechmap": return .czechMap
case "mluvnice": return .mluvnice
case "readinglist": return .readingList
case "cetba1984": return .book("1984")
case "cetba1984quiz": return .bookQuiz("1984")
case "cetba1984dej": return .bookPlot("1984")
case "cetbaFuks": return .book("fuks")
case "cetbaFuksQuiz": return .bookQuiz("fuks")
case "cetbaFuksDej": return .bookPlot("fuks")
default:
if let m = page.wholeMatch(of: /unit(\d+)/), let n = Int(m.1) {
return .unitMap(n - 1)
}
if let m = page.wholeMatch(of: /u(\d+)e(\d+)/), let u = Int(m.1), let e = Int(m.2) {
return .germanEx(u - 1, e)
}
if let m = page.wholeMatch(of: /u(\d+)vocab/), let u = Int(m.1) {
return .vocab(u - 1)
}
if let m = page.wholeMatch(of: /netunit(\d+)/), let n = Int(m.1) {
return .netLesson(n)
}
if let m = page.wholeMatch(of: /netex(\d+)/), let n = Int(m.1) {
return .netEx(n)
}
if let m = page.wholeMatch(of: /hwunit(\d+)/), let n = Int(m.1) {
return .hwLesson(n)
}
if let m = page.wholeMatch(of: /hwex(\d+)/), let n = Int(m.1) {
return .hwEx(n)
}
if let m = page.wholeMatch(of: /mluve(\d+)/), let n = Int(m.1) {
return .mluvEx(n)
}
return nil
}
}

View file

@ -0,0 +1,276 @@
import SwiftUI
struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
arrange(width: proposal.width ?? .infinity, subviews: subviews).size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrange(width: bounds.width, subviews: subviews)
for (i, frame) in result.frames.enumerated() {
subviews[i].place(
at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY),
proposal: ProposedViewSize(frame.size)
)
}
}
private func arrange(width: CGFloat, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) {
var frames: [CGRect] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowH: CGFloat = 0
var maxW: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x > 0 && x + size.width > width {
x = 0
y += rowH + spacing
rowH = 0
}
frames.append(CGRect(origin: CGPoint(x: x, y: y), size: size))
x += size.width + spacing
rowH = max(rowH, size.height)
maxW = max(maxW, x - spacing)
}
return (CGSize(width: max(maxW, 0), height: y + rowH), frames)
}
}
struct PrimaryButton: View {
let label: String
let palette: Palette
var action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(palette.onAccent)
.padding(.horizontal, 22)
.padding(.vertical, 12)
.background(palette.accent, in: Capsule())
}
.buttonStyle(.plain)
}
}
struct PillButton: View {
let label: String
let palette: Palette
var action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(palette.text)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(palette.text.opacity(0.06), in: Capsule())
}
.buttonStyle(.plain)
}
}
struct IconHit<Content: View>: View {
var action: () -> Void
@ViewBuilder var content: () -> Content
var body: some View {
Button(action: action) {
content()
.frame(width: 40, height: 40)
}
.buttonStyle(.plain)
}
}
struct PageTop: View {
@ObservedObject var vm: AppModel
var back: () -> Void
let title: String
var subtitle: String?
var body: some View {
let p = vm.palette
VStack(spacing: 6) {
ZStack {
Text(title)
.font(.system(size: 22, weight: .medium))
.foregroundStyle(p.text)
.multilineTextAlignment(.center)
.padding(.horizontal, 48)
HStack {
IconHit(action: back) { BackIcon(color: p.text) }
Spacer()
}
}
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.system(size: 14))
.foregroundStyle(p.subtext)
.multilineTextAlignment(.center)
}
}
.padding(.top, 4)
.padding(.bottom, 14)
}
}
struct CardBox<Content: View>: View {
let palette: Palette
@ViewBuilder var content: () -> Content
var body: some View {
content()
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(palette.text.opacity(0.07), lineWidth: 1)
)
}
}
struct FeedbackLine: View {
let text: String
let kind: String
let palette: Palette
var body: some View {
if !text.isEmpty {
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
.padding(.top, 8)
}
}
}
struct Meaning: View {
let text: String
let palette: Palette
let visible: Bool
var body: some View {
if visible && !text.isEmpty {
Text(text)
.font(.system(size: 13))
.foregroundStyle(palette.subtext)
.padding(.bottom, 8)
}
}
}
struct WordField: View {
@Binding var value: String
let palette: Palette
var placeholder: String
var mark: Bool?
var body: some View {
let border: Color = {
if mark == true { return palette.success }
if mark == false { return palette.error }
return palette.text.opacity(0.12)
}()
ZStack(alignment: .leading) {
if value.isEmpty {
Text(placeholder).foregroundStyle(palette.overlay).font(.system(size: 15))
}
TextField("", text: $value)
.font(.system(size: 15))
.foregroundStyle(palette.text)
.tint(palette.accent)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(palette.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1))
}
}
struct SegChip: View {
let label: String
let selected: Bool
let palette: Palette
var action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.text)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(selected ? palette.mantle : Color.clear, in: Capsule())
}
.buttonStyle(.plain)
}
}
struct Hint: View {
let text: String
let palette: Palette
var body: some View {
Text(text).font(.system(size: 13)).foregroundStyle(palette.subtext).padding(.bottom, 8)
}
}
struct Prompt: View {
let text: String
let palette: Palette
var body: some View {
Text(text).font(.system(size: 16, weight: .medium)).foregroundStyle(palette.text)
}
}
struct WordChip: View {
let text: String
let palette: Palette
var action: () -> Void
var body: some View {
Button(action: action) {
Text(text)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(palette.mantle, in: Capsule())
.overlay(Capsule().stroke(palette.text.opacity(0.1), lineWidth: 1))
}
.buttonStyle(.plain)
}
}
struct OptionChip: View {
let text: String
let selected: Bool
let mark: Bool?
let palette: Palette
var action: () -> Void
var body: some View {
let border: Color = {
if mark == true { return palette.success }
if mark == false { return palette.error }
return selected ? palette.accent : palette.text.opacity(0.1)
}()
Button(action: action) {
Text(text)
.font(.system(size: 15))
.foregroundStyle(palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(selected ? palette.surface1 : palette.mantle, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1))
}
.buttonStyle(.plain)
.padding(.bottom, 6)
}
}

View file

@ -0,0 +1,608 @@
import SwiftUI
struct ExerciseScaffold<Content: View>: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let action: String
var onAction: () -> Void
let feedback: String
let kind: String
@ViewBuilder var content: () -> Content
var body: some View {
let p = vm.palette
VStack(alignment: .leading, spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
ScrollView {
VStack(alignment: .leading, spacing: 0) { content() }
}
FeedbackLine(text: feedback, kind: kind, palette: p)
PrimaryButton(label: action, palette: p, action: onAction)
.padding(.vertical, 12)
}
.padding(.horizontal, 16)
}
}
struct GermanExercise: View {
@ObservedObject var vm: AppModel
let unitId: Int
let ex: Int
var body: some View {
if let spec = vm.content.germanUnit(unitId)?.exercise(ex) {
DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) {
vm.markGerman(unitId, ex)
}
}
}
}
struct VocabExercise: View {
@ObservedObject var vm: AppModel
let unitId: Int
var body: some View {
if let spec = vm.content.germanUnit(unitId)?.obj("vocab") {
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr("Vokabeltraining")) {
vm.markVocab(unitId)
}
}
}
}
struct MluvExercise: View {
@ObservedObject var vm: AppModel
let n: Int
var body: some View {
if let spec = vm.content.mluv(n) {
DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) {
vm.markMluv(n)
}
}
}
}
struct DispatchExercise: View {
@ObservedObject var vm: AppModel
let spec: J
let titleFallback: String
var onDone: () -> Void
var body: some View {
let title = spec.str("title").isEmpty ? titleFallback : spec.str("title")
let sub = spec.strOrNull("sub").map { vm.tr($0) }
switch spec.str("type") {
case "choice": ChoiceEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "assembly": AssemblyEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "typed", "kw": TypedEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "free", "profile": FreeEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "assign": AssignEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "hangman": HangmanEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "vocab": VocabEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "dialog": DialogEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "number", "count", "seq", "verb", "verbclue", "verb_sections", "ordne", "fill":
ComboEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "ex2": Ex2Ex(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "letters", "letters_gap": LettersEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "table": TableEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "reveal": RevealEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
default: Text("?").foregroundStyle(vm.palette.text)
}
}
}
private struct ChoiceEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var picks: [Int]
@State private var fb = ("", "")
@State private var revealed = false
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
_picks = State(initialValue: Array(repeating: -1, count: spec.arr("questions").count))
}
var body: some View {
let qs = spec.arr("questions")
let meanings = spec.strs("meanings")
let expls = spec.strs("expls")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
let ok = qs.indices.filter { picks[$0] == qs[$0].int("correct") }.count
if ok == qs.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_retry_short"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
ForEach(qs.indices, id: \.self) { i in
Text("\(i + 1). \(qs[i].str("prompt"))")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.text)
.padding(.bottom, 6)
ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in
let sel = picks[i] == o
let mark: Bool? = revealed && sel ? picks[i] == qs[i].int("correct") : nil
OptionChip(text: opt, selected: sel, mark: mark, palette: p) { picks[i] = o }
}
if revealed {
if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
if i < expls.count { Meaning(text: expls[i], palette: p, visible: true) }
}
Spacer().frame(height: 10)
}
}
}
}
private struct AssemblyEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var placed: [[Int]]
@State private var pools: [[Int]]
@State private var fb = ("", "")
@State private var revealed = false
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
let items = spec.arr("items")
_placed = State(initialValue: items.map { _ in [] })
_pools = State(initialValue: items.map { item in
Array(0..<item.strs("words").count).shuffled()
})
}
var body: some View {
let items = spec.arr("items")
let meanings = spec.strs("meanings")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
let ok = items.indices.allSatisfy { i in
let words = items[i].strs("words")
return placed[i].count == words.count && placed[i].map { words[$0] } == words
}
if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_sentences"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
ForEach(items.indices, id: \.self) { i in
if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt, palette: p) }
FlowLayout {
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) }
ForEach(placed[i], id: \.self) { idx in
if idx < items[i].strs("words").count {
WordChip(text: items[i].strs("words")[idx], palette: p) {
placed[i].removeAll { $0 == idx }
pools[i].append(idx)
}
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.bottom, 6)
FlowLayout(spacing: 6) {
ForEach(pools[i], id: \.self) { idx in
if idx < items[i].strs("words").count {
WordChip(text: items[i].strs("words")[idx], palette: p) {
pools[i].removeAll { $0 == idx }
placed[i].append(idx)
}
}
}
}
if revealed, i < meanings.count {
Meaning(text: vm.tr(meanings[i]), palette: p, visible: true)
}
Spacer().frame(height: 12)
}
}
}
}
private struct TypedEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var fields: [[String]]
@State private var fb = ("", "")
@State private var revealed = false
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows")
_fields = State(initialValue: items.map { q in
Array(repeating: "", count: max(1, q.strs("hints").count, q.answerList().count))
})
}
var body: some View {
let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows")
let p = vm.palette
let umlaut = items.contains { q in
q.answerList().contains { hasUmlaut($0) } || hasUmlaut(q.str("prompt"))
}
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
var ok = 0, total = 0, incomplete = false
for (i, q) in items.enumerated() {
let answers = q.answerList()
for (j, value) in fields[i].enumerated() {
total += 1
let ans = j < answers.count ? answers[j] : ""
let norm = normalizeAnswer(value)
if answerAccepts(norm, ans) { ok += 1 }
else if answerIncomplete(norm, ans) { incomplete = true }
}
}
if total > 0 && ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else if incomplete { fb = (vm.tr("trans_incomplete"), "warn") }
else { fb = (vm.tr("feedback_retry"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
if let note = spec.strOrNull("note") { Hint(text: note, palette: p) }
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: p) }
if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)", palette: p) }
if umlaut { Hint(text: vm.tr("hint_umlauts"), palette: p) }
ForEach(items.indices, id: \.self) { i in
let q = items[i]
Prompt(text: q.str("prompt"), palette: p)
let answers = q.answerList()
let hints = q.strs("hints")
ForEach(fields[i].indices, id: \.self) { j in
let ans = j < answers.count ? answers[j] : ""
WordField(
value: Binding(get: { fields[i][j] }, set: { fields[i][j] = $0 }),
palette: p,
placeholder: j < hints.count ? hints[j] : vm.tr("your_answer"),
mark: revealed ? answerAccepts(normalizeAnswer(fields[i][j]), ans) : nil
)
.padding(.bottom, 4)
}
if revealed {
if let m = q.strOrNull("meaning") { Meaning(text: vm.tr(m), palette: p, visible: true) }
if let m = q.strOrNull("mean") { Meaning(text: vm.tr(m), palette: p, visible: true) }
if let g = q.strOrNull("german") { Meaning(text: g, palette: p, visible: true) }
if let s = q.strOrNull("shown") { Meaning(text: s, palette: p, visible: true) }
}
Spacer().frame(height: 10)
}
}
}
}
private struct FreeEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var shown: [Bool]
@State private var values: [String]
@State private var fb = ("", "")
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions")
_shown = State(initialValue: Array(repeating: false, count: qs.count))
_values = State(initialValue: Array(repeating: "", count: qs.count))
}
var body: some View {
let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: {
fb = (vm.tr("feedback_ok"), "ok")
shown = shown.map { _ in true }
onDone()
}, feedback: fb.0, kind: fb.1) {
if let tip = spec.strOrNull("tip") { Hint(text: vm.tr(tip), palette: p) }
ForEach(qs.indices, id: \.self) { i in
let q = qs[i]
Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question"), palette: p)
WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), palette: p, placeholder: vm.tr("your_answer"))
PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() }
.padding(.vertical, 6)
if shown[i] {
let sample = q.str("sample")
let qcs = q.str("qCs")
let acs = q.str("aCs")
Meaning(
text: qcs.isEmpty ? sample : vm.fmt("sample_fmt", vm.tr(qcs), sample, vm.tr(acs)),
palette: p,
visible: true
)
}
Spacer().frame(height: 10)
}
}
}
}
private struct AssignEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var active = 0
@State private var loc: [Int]
@State private var fb = ("", "")
@State private var revealed = false
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
_loc = State(initialValue: Array(repeating: -1, count: spec.arr("items").count))
}
var body: some View {
let items = spec.arr("items")
let groups = spec.strs("groups")
let meanings = spec.strs("meanings")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
let ok = items.indices.allSatisfy { loc[$0] == items[$0].int("group") }
if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_retry"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
Hint(text: vm.tr("assign_hint"), palette: p)
FlowLayout(spacing: 8) {
ForEach(groups.indices, id: \.self) { i in
SegChip(label: groups[i], selected: active == i, palette: p) { active = i }
}
}
Text(vm.tr("wordbank")).font(.system(size: 12)).foregroundStyle(p.subtext).padding(.top, 10)
FlowLayout(spacing: 6) {
ForEach(items.indices, id: \.self) { i in
if loc[i] == -1 {
let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
WordChip(text: label, palette: p) { loc[i] = active }
}
}
}
ForEach(groups.indices, id: \.self) { g in
Text(groups[g]).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 10)
FlowLayout(spacing: 6) {
ForEach(items.indices, id: \.self) { i in
if loc[i] == g {
let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
WordChip(text: label, palette: p) { loc[i] = -1 }
}
}
}
}
if revealed {
ForEach(items.indices, id: \.self) { i in
if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
}
}
}
}
}
private struct HangmanEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var word = 0
@State private var misses = 0
@State private var guessed: [Bool]
@State private var fb = ("", "")
@State private var finished = false
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
_guessed = State(initialValue: Array(repeating: false, count: spec.strs("letters").count))
}
var body: some View {
let words = spec.strs("words")
let tips = spec.strs("tips")
let letters = spec.strs("letters")
let p = vm.palette
let cur = word < words.count ? words[word].uppercased() : ""
VStack(alignment: .leading, spacing: 8) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(p.subtext)
if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))", palette: p) }
Canvas { ctx, size in
var gallows = Path()
gallows.move(to: CGPoint(x: 40, y: size.height - 10))
gallows.addLine(to: CGPoint(x: size.width * 0.55, y: size.height - 10))
gallows.move(to: CGPoint(x: 70, y: size.height - 10))
gallows.addLine(to: CGPoint(x: 70, y: 16))
gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, y: 16))
ctx.stroke(gallows, with: .color(p.text), style: StrokeStyle(lineWidth: 6, lineCap: .round))
let x = size.width * 0.3 + 40
if misses > 0 {
var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36))
ctx.stroke(rope, with: .color(p.text), lineWidth: 4)
}
if misses > 1 {
ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(p.text), lineWidth: 4)
}
if misses > 2 {
var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110))
ctx.stroke(body, with: .color(p.text), lineWidth: 4)
}
if misses > 3 {
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100))
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
}
if misses > 4 {
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100))
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
}
if misses > 5 {
var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140))
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
}
}
.frame(height: 160)
HStack(spacing: 6) {
ForEach(Array(cur.enumerated()), id: \.offset) { _, ch in
let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
let show = idx.map { guessed[$0] } ?? false
Text(show ? String(ch) : "_")
.font(.system(size: 26, weight: .bold))
.foregroundStyle(p.text)
}
}
ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in
HStack(spacing: 4) {
ForEach(row, id: \.self) { letter in
if let i = letters.firstIndex(of: letter) {
Button {
guessed[i] = true
if !cur.localizedCaseInsensitiveContains(letter) {
misses += 1
if misses >= 6 { fb = (vm.fmt("hm_fail", cur), "err") }
} else if cur.allSatisfy({ ch in
let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
return idx.map { guessed[$0] } ?? false
}) {
if word + 1 >= words.count {
finished = true
fb = (vm.tr("hm_done"), "ok")
onDone()
} else {
fb = (vm.tr("hm_wrong"), "ok")
word += 1
misses = 0
guessed = guessed.map { _ in false }
}
}
} label: {
Text(letter)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(p.text)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(guessed[i] ? p.surface1 : p.mantle, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(guessed[i] || finished)
}
}
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
if misses >= 6 && !finished {
PillButton(label: vm.tr("hm_retry"), palette: p) {
misses = 0
guessed = guessed.map { _ in false }
fb = ("", "")
}
}
Spacer()
}
.padding(.horizontal, 16)
}
}
private extension Array {
func chunked(_ size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
}
}
private struct VocabEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var cards: [(String, String, String)]
@State private var index = 0
@State private var done = 0
@State private var value = ""
@State private var fb = ("", "")
let total: Int
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
let list = spec.arr("sections").flatMap { sec in
sec.arr("rows").map { (sec.str("header"), $0.str("prompt"), $0.str("answers")) }
}
_cards = State(initialValue: list)
total = list.count
}
var body: some View {
let p = vm.palette
VStack(alignment: .leading, spacing: 8) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
Text(vm.fmt("trans_progress", done, total)).foregroundStyle(p.subtext)
if cards.isEmpty {
Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success)
} else if index < cards.count {
let cur = cards[index]
Hint(text: cur.0, palette: p)
Prompt(text: cur.1, palette: p)
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts"), palette: p) }
WordField(value: $value, palette: p, placeholder: vm.tr("your_answer"))
PrimaryButton(label: vm.tr("check"), palette: p) {
let norm = normalizeAnswer(value)
if answerAccepts(norm, cur.2) {
done += 1
cards.remove(at: index)
if cards.isEmpty { fb = (vm.tr("trans_done"), "ok"); onDone() }
else { if index >= cards.count { index = 0 }; value = ""; fb = ("", "") }
} else if answerIncomplete(norm, cur.2) {
fb = (vm.tr("trans_incomplete"), "warn")
} else {
fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err")
let item = cards.remove(at: index)
cards.append(item)
if index >= cards.count { index = 0 }
value = ""
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
}
Spacer()
}
.padding(.horizontal, 16)
}
}

View file

@ -0,0 +1,857 @@
import SwiftUI
struct DialogEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
var body: some View {
let pool = spec.strs("pool")
let meanings = spec.strs("meanings")
let lines = dialogLines()
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: lines.count, onDone: onDone) { picks, expected, revealed in
ForEach(lines.indices, id: \.self) { i in
let line = lines[i]
if let name = line.name {
Text(name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(vm.palette.subtext)
.padding(.vertical, 6)
}
ComboLine(
vm: vm, pool: pool, picks: picks, expected: expected, idx: i,
answer: line.answer,
prefix: line.prefix,
suffix: line.suffix,
meaning: i < meanings.count ? meanings[i] : nil,
revealed: revealed
)
}
}
}
private func dialogLines() -> [(name: String?, prefix: String, answer: String, suffix: String)] {
var out: [(name: String?, prefix: String, answer: String, suffix: String)] = []
for d in spec.arr("dialogues") {
var header: String? = d.str("name")
for row in d.arr("rows") {
out.append((
header,
[row.str("speaker"), row.str("before")].filter { !$0.isEmpty }.joined(separator: " "),
row.str("answer"),
row.str("after")
))
header = nil
}
}
return out
}
}
struct ComboEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
var body: some View {
let pool = spec.strs("pool")
let rows = spec.arr("rows")
let meanings = spec.strs("meanings")
let type = spec.str("type")
if type == "verb_sections" {
let sections = spec.arr("sections")
let allRows = sections.flatMap { $0.arr("rows") }
ComboRows(vm: vm, title: title, sub: sub, pool: spec.strs("pool"), n: allRows.count, onDone: onDone, revealMean: spec.bool("transUpfront")) { picks, expected, revealed in
ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in
if let title = line.title {
Text(title).font(.system(size: 15, weight: .semibold)).foregroundStyle(vm.palette.subtext)
}
ComboLine(
vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx,
answer: line.answer, prefix: line.before, suffix: line.after,
meaning: line.meaning,
revealed: revealed || spec.bool("transUpfront")
)
}
}
} else if type == "ordne" {
ComboRows(vm: vm, title: title, sub: sub, pool: [], n: rows.count * 2, onDone: onDone) { picks, expected, revealed in
ForEach(rows.indices, id: \.self) { i in
ComboLine(vm: vm, pool: spec.strs("fwPool"), picks: picks, expected: expected, idx: i * 2, answer: rows[i].str("fw"), prefix: "\(i + 1). ", suffix: rows[i].str("mid"), revealed: revealed)
ComboLine(vm: vm, pool: spec.strs("ansPool"), picks: picks, expected: expected, idx: i * 2 + 1, answer: rows[i].str("ans"), prefix: "→ ", meaning: i < meanings.count ? meanings[i] : nil, revealed: revealed)
}
}
} else if type == "fill" {
let blanks = rows.reduce(0) { $0 + $1.answerList().count }
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) }
ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
FlowLayout(spacing: 4) {
if let num = block.num {
Text(num).font(.system(size: 16, weight: .semibold)).foregroundStyle(vm.palette.text)
}
ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(vm.palette.text)
case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
}
}
if let mean = block.meaning {
Text(vm.tr(mean)).font(.system(size: 12)).foregroundStyle(vm.palette.subtext)
}
}
.padding(.bottom, 6)
if revealed || spec.bool("preMeaning") || spec.bool("showGerman"), let mean = block.rowMean {
Meaning(text: vm.tr(mean), palette: vm.palette, visible: true)
}
}
}
} else {
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: rows.count, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) }
ForEach(rows.indices, id: \.self) { i in
let r = rows[i]
let prefix: String = {
switch type {
case "number": return "\(r.str("digits")) = "
case "count": return "\(Array(repeating: r.str("emoji"), count: r.int("count")).joined(separator: " ")) \(r.str("noun")) "
case "seq": return r.str("before")
case "verbclue": return "\(r.str("emoji")) \(r.str("before"))"
default: return r.str("before")
}
}()
let suffix: String = {
switch type {
case "seq", "verbclue", "verb": return r.str("after")
default: return ""
}
}()
ComboLine(
vm: vm, pool: pool, picks: picks, expected: expected, idx: i, answer: r.str("answer"),
prefix: prefix, suffix: suffix,
meaning: i < meanings.count ? meanings[i] : r.strOrNull("mean"),
revealed: revealed || spec.bool("preMeaning")
)
}
}
}
}
private func verbSectionLines() -> [(title: String?, before: String, answer: String, after: String, meaning: String?)] {
var out: [(title: String?, before: String, answer: String, after: String, meaning: String?)] = []
for sec in spec.arr("sections") {
var header: String? = sec.str("title")
let meanings = sec.strs("meanings")
for (ri, row) in sec.arr("rows").enumerated() {
out.append((
header,
row.str("before"),
row.str("answer"),
row.str("after"),
ri < meanings.count ? meanings[ri] : nil
))
header = nil
}
}
return out
}
private enum FillPart {
case text(String)
case blank(Int, String)
}
private func fillRows() -> [(num: String?, parts: [FillPart], meaning: String?, rowMean: String?)] {
let meanings = spec.strs("meanings")
var i = 0
return spec.arr("rows").enumerated().map { ri, r in
var parts: [FillPart] = []
let segs = r.strsOrEmpty("segs")
let ans = r.answerList()
for si in segs.indices {
if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
if si < ans.count {
parts.append(.blank(i, ans[si]))
i += 1
}
}
return (
r.strOrNull("num"),
parts,
ri < meanings.count ? meanings[ri] : nil,
r.strOrNull("mean")
)
}
}
}
private struct ComboRows<Body: View>: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let pool: [String]
let n: Int
var onDone: () -> Void
var revealMean = false
@ViewBuilder var bodyContent: (Binding<[String]>, Binding<[String]>, Bool) -> Body
@State private var picks: [String]
@State private var expected: [String]
@State private var fb = ("", "")
@State private var revealed: Bool
init(
vm: AppModel,
title: String,
sub: String?,
pool: [String],
n: Int,
onDone: @escaping () -> Void,
revealMean: Bool = false,
@ViewBuilder bodyContent: @escaping (Binding<[String]>, Binding<[String]>, Bool) -> Body
) {
self.vm = vm
self.title = title
self.sub = sub
self.pool = pool
self.n = n
self.onDone = onDone
self.revealMean = revealMean
self.bodyContent = bodyContent
_picks = State(initialValue: Array(repeating: "", count: n))
_expected = State(initialValue: Array(repeating: "", count: n))
_revealed = State(initialValue: revealMean)
}
var body: some View {
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
let ok = expected.indices.allSatisfy { i in
let exp = expected[i]
return exp.isEmpty || answerAccepts(normalizeAnswer(picks[i]), exp)
}
if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_retry"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
bodyContent($picks, $expected, revealed)
}
}
}
private struct ComboLine: View {
@ObservedObject var vm: AppModel
let pool: [String]
@Binding var picks: [String]
@Binding var expected: [String]
let idx: Int
let answer: String
var prefix: String = ""
var suffix: String = ""
var meaning: String?
var revealed: Bool
var inline = false
@State private var open = false
var body: some View {
let p = vm.palette
let pick = idx < picks.count ? picks[idx] : ""
let mark: Bool? = {
if revealed && !pick.isEmpty { return answerAccepts(normalizeAnswer(pick), answer) }
return nil
}()
let chip = Button {
open = true
} label: {
Text(pick.isEmpty ? "—" : pick)
.font(.system(size: 15))
.foregroundStyle(pick.isEmpty ? p.overlay : p.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(p.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(mark == true ? p.success : mark == false ? p.error : p.text.opacity(0.12), lineWidth: 1)
)
}
.buttonStyle(.plain)
Group {
if inline {
chip
} else {
FlowLayout(spacing: 6) {
if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) }
chip
if !suffix.isEmpty { Text(suffix).font(.system(size: 16)).foregroundStyle(p.text) }
}
.padding(.bottom, 8)
}
}
.onAppear {
if idx >= 0 && idx < expected.count { expected[idx] = answer }
}
.onChange(of: answer) { new in
if idx >= 0 && idx < expected.count { expected[idx] = new }
}
.sheet(isPresented: $open) {
WordPicker(palette: p, pool: pool) { word in
if let word, idx < picks.count { picks[idx] = word }
open = false
}
.presentationDetents([.medium, .large])
}
if revealed, let meaning, !meaning.isEmpty {
Meaning(text: vm.tr(meaning), palette: p, visible: true)
}
}
}
struct WordPicker: View {
let palette: Palette
let pool: [String]
var onPick: (String?) -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(pool, id: \.self) { w in
Button { onPick(w) } label: {
Text(w)
.font(.system(size: 16))
.foregroundStyle(palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
}
.padding(10)
}
.background(palette.base)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(role: .cancel) { onPick(nil) } label: { Text("Zavřít") }
}
}
}
}
}
struct Ex2Ex: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
var body: some View {
let items = spec.arr("items")
let pool = spec.strs("pool")
let blanks = items.reduce(0) { acc, item in
acc + item.arr("rows").reduce(0) { $0 + $1.strs("answers").count }
}
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone) { picks, expected, revealed in
ForEach(Array(ex2Blocks().enumerated()), id: \.offset) { _, block in
Meaning(text: block.czech, palette: vm.palette, visible: true)
ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in
FlowLayout(spacing: 4) {
if let num = row.num {
Text(num).foregroundStyle(vm.palette.text).padding(.trailing, 6)
}
ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(vm.palette.text)
case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
}
}
}
}
}
}
}
private enum Part {
case text(String)
case blank(Int, String)
}
private func ex2Blocks() -> [(czech: String, rows: [(num: String?, parts: [Part])])] {
var i = 0
return spec.arr("items").map { item in
let rows = item.arr("rows").map { row -> (num: String?, parts: [Part]) in
var parts: [Part] = []
let segs = row.strsOrEmpty("segs")
let ans = row.strs("answers")
for si in segs.indices {
if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
if si < ans.count {
parts.append(.blank(i, ans[si]))
i += 1
}
}
return (row.strOrNull("num"), parts)
}
return (item.str("czech"), rows)
}
}
}
struct LettersEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var fields: [String]
@State private var fb = ("", "")
@State private var revealed = false
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
let n = spec.arr("rows").flatMap { $0.strs("answers") }.count
_fields = State(initialValue: Array(repeating: "", count: n))
}
var body: some View {
let rows = spec.arr("rows")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
var i = 0, ok = 0, total = 0
for r in rows {
for ans in r.strs("answers") {
total += 1
if i < fields.count && answerAccepts(normalizeAnswer(fields[i]), ans) { ok += 1 }
i += 1
}
}
if ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_retry"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
if rows.contains(where: { $0.strs("answers").contains { hasUmlaut($0) } }) {
Hint(text: vm.tr("hint_umlauts"), palette: p)
}
LettersRows(vm: vm, rows: rows, fields: $fields, revealed: revealed)
}
}
}
private struct LettersRows: View {
@ObservedObject var vm: AppModel
let rows: [J]
@Binding var fields: [String]
let revealed: Bool
var body: some View {
let p = vm.palette
let pairs = build()
VStack(alignment: .leading, spacing: 8) {
ForEach(pairs.indices, id: \.self) { ri in
let row = pairs[ri]
if let num = row.num { Text(num).foregroundStyle(p.subtext) }
FlowLayout {
ForEach(row.parts.indices, id: \.self) { pi in
switch row.parts[pi] {
case .text(let s):
Text(s).foregroundStyle(p.text)
case .field(let idx, let ans):
WordField(
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
palette: p,
placeholder: "",
mark: revealed ? answerAccepts(normalizeAnswer(idx < fields.count ? fields[idx] : ""), ans) : nil
)
.frame(width: 72)
}
}
}
if revealed, let czech = row.czech { Meaning(text: czech, palette: p, visible: true) }
if revealed, let mean = row.mean { Meaning(text: mean, palette: p, visible: true) }
}
}
}
private enum Part {
case text(String)
case field(Int, String)
}
private struct RowModel {
var num: String?
var parts: [Part]
var czech: String?
var mean: String?
}
private func build() -> [RowModel] {
var i = 0
return rows.map { r in
var parts: [Part] = []
let segs = r.strsOrEmpty("segs")
let ans = r.strs("answers")
for si in segs.indices {
if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
if si < ans.count {
parts.append(.field(i, ans[si]))
i += 1
}
}
return RowModel(num: r.strOrNull("num"), parts: parts, czech: r.strOrNull("czech"), mean: r.strOrNull("mean"))
}
}
}
struct TableEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var fields: [String]
@State private var fb = ("", "")
@State private var revealed = false
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
_fields = State(initialValue: Array(repeating: "", count: spec.strs("nouns").count * spec.strs("persons").count))
}
var body: some View {
let persons = spec.strs("persons")
let nouns = spec.strs("nouns")
let answers = spec.intRows("answers")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
var ok = 0
for (r, row) in answers.enumerated() {
for (c, ans) in row.enumerated() {
let i = r * persons.count + c
if i < fields.count && normalizeAnswer(fields[i]) == normalizeAnswer(ans) { ok += 1 }
}
}
if ok == fields.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
else { fb = (vm.tr("feedback_retry"), "err") }
revealed = true
}, feedback: fb.0, kind: fb.1) {
ForEach(nouns.indices, id: \.self) { r in
Text(nouns[r]).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
ForEach(persons.indices, id: \.self) { c in
let i = r * persons.count + c
Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext)
WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), palette: p, placeholder: "")
}
Spacer().frame(height: 8)
}
}
}
}
struct RevealEx: View {
@ObservedObject var vm: AppModel
let title: String
var sub: String?
let spec: J
var onDone: () -> Void
@State private var shown: [Bool]
@State private var fb = ("", "")
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
self.vm = vm
self.title = title
self.sub = sub
self.spec = spec
self.onDone = onDone
_shown = State(initialValue: Array(repeating: false, count: spec.arr("items").count))
}
var body: some View {
let items = spec.arr("items")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: {
shown = shown.map { _ in true }
fb = (vm.tr("feedback_ok"), "ok")
onDone()
}, feedback: fb.0, kind: fb.1) {
if let note = spec.strOrNull("note") { Hint(text: note, palette: p) }
ForEach(items.indices, id: \.self) { i in
Prompt(text: items[i].str("prompt"), palette: p)
PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() }
.padding(.vertical, 6)
if shown[i] { Meaning(text: items[i].str("solution"), palette: p, visible: true) }
Spacer().frame(height: 8)
}
}
}
}
struct NetQuizScreen: View {
@ObservedObject var vm: AppModel
let id: Int
var body: some View {
if let lesson = vm.content.netLesson(id) {
if id == 1 {
VlsmEx(vm: vm, lesson: lesson)
} else {
let spec = J([
"type": "choice",
"title": vm.tr(lesson.str("exTitleKey")),
"sub": "net_quiz_intro",
"questions": lesson.rawArray("quiz"),
])
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
vm.markNet(id)
}
}
}
}
}
struct HwQuizScreen: View {
@ObservedObject var vm: AppModel
let id: Int
var body: some View {
if let lesson = vm.content.hwLesson(id) {
let spec = J([
"type": "choice",
"title": vm.tr(lesson.str("exTitleKey")),
"sub": lesson.str("quizHeadKey"),
"questions": lesson.rawArray("quiz"),
])
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
vm.markHw(id)
}
}
}
}
private struct VlsmRow {
var pfx = ""
var net = ""
var bc = ""
var lo = ""
var hi = ""
}
private struct VlsmEx: View {
@ObservedObject var vm: AppModel
let lesson: J
@State private var done: [Bool]
@State private var rows: [[VlsmRow]]
@State private var fb = ("", "")
init(vm: AppModel, lesson: J) {
self.vm = vm
self.lesson = lesson
let answers = vm.content.netAnswers
_done = State(initialValue: Array(repeating: false, count: vm.content.netTasks.count))
_rows = State(initialValue: answers.map { block in Array(repeating: VlsmRow(), count: block.count) })
}
var body: some View {
let tasks = vm.content.netTasks
let answers = vm.content.netAnswers
let p = vm.palette
VStack(alignment: .leading, spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_ex_title"), subtitle: nil)
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(tasks.indices, id: \.self) { ti in
let task = tasks[ti]
let block = ti < answers.count ? answers[ti] : []
let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 }
Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8)
if infeasible {
Hint(text: vm.tr("net_infeasible"), palette: p)
} else {
ForEach(block.indices, id: \.self) { si in
Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(p.subtext)
PrefixMenu(value: Binding(
get: { rows[ti][si].pfx },
set: { rows[ti][si].pfx = $0 }
), palette: p)
WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), palette: p, placeholder: "síť")
WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), palette: p, placeholder: "broadcast")
WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), palette: p, placeholder: "první uzel")
WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), palette: p, placeholder: "poslední uzel")
}
PillButton(label: vm.tr("check"), palette: p) {
let ok = block.indices.allSatisfy { si in
let a = block[si]
let field = rows[ti][si]
return Int(field.pfx.replacingOccurrences(of: "/", with: "")) == a.int("pfx")
&& netTxtEq(field.net, a.str("net"))
&& netTxtEq(field.bc, a.str("bcast"))
&& netTxtEq(field.lo, a.str("lo"))
&& netTxtEq(field.hi, a.str("hi"))
}
if ok {
done[ti] = true
fb = (vm.tr("feedback_ok"), "ok")
maybeFinish()
} else {
fb = (vm.tr("feedback_retry"), "err")
}
}
.padding(.vertical, 6)
}
PillButton(label: vm.tr("net_solution"), palette: p) {
if !infeasible {
for si in block.indices {
rows[ti][si].pfx = "/\(block[si].int("pfx"))"
rows[ti][si].net = block[si].str("net")
rows[ti][si].bc = block[si].str("bcast")
rows[ti][si].lo = block[si].str("lo")
rows[ti][si].hi = block[si].str("hi")
}
}
done[ti] = true
fb = ("", "")
maybeFinish()
}
if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) }
}
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
}
.padding(.horizontal, 16)
}
private func maybeFinish() {
if !done.isEmpty && done.allSatisfy({ $0 }) { vm.markNet(1) }
}
}
private struct PrefixMenu: View {
@Binding var value: String
let palette: Palette
@State private var open = false
var body: some View {
Button { open = true } label: {
Text(value.isEmpty ? "/…" : value)
.font(.system(size: 15))
.foregroundStyle(palette.text)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.padding(.vertical, 4)
.sheet(isPresented: $open) {
WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
if let word { value = word }
open = false
}
.presentationDetents([.medium, .large])
}
}
}
struct LitQuizScreen: View {
@ObservedObject var vm: AppModel
let id: String
@State private var idx = 0
@State private var score = 0
@State private var answered = false
@State private var finished = false
var body: some View {
if let book = vm.content.book(id) {
let qs = book.arr("quiz")
let p = vm.palette
let kahoot = [p.accent, p.error, p.warning, p.success]
VStack(alignment: .leading, spacing: 8) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
if finished {
Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext)
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
PrimaryButton(label: vm.tr("lit_again"), palette: p) {
idx = 0; score = 0; answered = false; finished = false
}
} else if idx < qs.count {
let q = qs[idx]
HStack {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)).foregroundStyle(p.subtext)
Spacer()
Text(vm.fmt("lit_score_fmt", score, qs.count)).foregroundStyle(p.subtext)
}
Text(q.str("prompt")).font(.system(size: 18, weight: .semibold)).foregroundStyle(p.text).padding(.vertical, 12)
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
Button {
answered = true
if i == q.int("correct") { score += 1 }
} label: {
Text(opt)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.onAccent)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(kahoot[i % 4], in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
.disabled(answered)
.padding(.bottom, 8)
}
if answered {
Meaning(text: q.str("expl"), palette: p, visible: true)
PrimaryButton(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), palette: p) {
if idx + 1 >= qs.count {
finished = true
vm.markBookQuiz(id)
} else {
idx += 1
answered = false
}
}
}
}
Spacer()
}
.padding(16)
}
}
}
struct PlotScreen: View {
@ObservedObject var vm: AppModel
let id: String
var body: some View {
if let book = vm.content.book(id) {
let spec = J([
"type": "assembly",
"title": vm.tr(book.str("plotTitle")),
"sub": book.str("plotSub"),
"items": book.rawArray("plot"),
"meanings": book.rawArray("plotMeaning"),
])
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(book.str("plotTitle"))) {
vm.markBookPlot(id)
}
}
}
}

304
ios/Maturita/UI/Icons.swift Normal file
View file

@ -0,0 +1,304 @@
import SwiftUI
struct BackIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.18))
p.addLine(to: CGPoint(x: sz.width * 0.28, y: sz.height * 0.5))
p.addLine(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.82))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct SearchIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.28
let c = CGPoint(x: sz.width * 0.42, y: sz.height * 0.42)
ctx.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.12)
var handle = Path()
handle.move(to: CGPoint(x: sz.width * 0.62, y: sz.height * 0.62))
handle.addLine(to: CGPoint(x: sz.width * 0.82, y: sz.height * 0.82))
ctx.stroke(handle, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.12, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct StatsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let w = min(sz.width, sz.height) * 0.16
let bars: [(CGFloat, CGFloat, CGFloat)] = [
(0.18, 0.48, 0.36), (0.42, 0.22, 0.62), (0.66, 0.36, 0.48),
]
for b in bars {
let rect = CGRect(x: sz.width * b.0, y: sz.height * b.1, width: w, height: sz.height * b.2)
ctx.fill(Path(roundedRect: rect, cornerRadius: w), with: .color(color))
}
}
.frame(width: size, height: size)
}
}
struct SettingsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let cx = sz.width / 2
let cy = sz.height / 2
let s = min(sz.width, sz.height)
let rOuter = s * 0.48
let rInner = s * 0.34
let rHole = s * 0.16
let teeth = 8
let toothHalf = CGFloat.pi / CGFloat(teeth)
var gear = Path()
for i in 0..<teeth {
let a = -CGFloat.pi / 2 + 2 * CGFloat.pi * CGFloat(i) / CGFloat(teeth)
let a0 = a - toothHalf * 0.55
let a1 = a - toothHalf * 0.28
let a2 = a + toothHalf * 0.28
let a3 = a + toothHalf * 0.55
let p0 = CGPoint(x: cx + cos(a0) * rInner, y: cy + sin(a0) * rInner)
if i == 0 { gear.move(to: p0) } else { gear.addLine(to: p0) }
gear.addLine(to: CGPoint(x: cx + cos(a1) * rOuter, y: cy + sin(a1) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a2) * rOuter, y: cy + sin(a2) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a3) * rInner, y: cy + sin(a3) * rInner))
}
gear.closeSubpath()
gear.addEllipse(in: CGRect(x: cx - rHole, y: cy - rHole, width: rHole * 2, height: rHole * 2))
ctx.fill(gear, with: .color(color), style: FillStyle(eoFill: true))
}
.frame(width: size, height: size)
}
}
struct LockIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let body = CGRect(x: sz.width * 0.28, y: sz.height * 0.46, width: sz.width * 0.44, height: sz.height * 0.36)
ctx.fill(Path(roundedRect: body, cornerRadius: min(sz.width, sz.height) * 0.08), with: .color(color))
var arc = Path()
arc.addArc(
center: CGPoint(x: sz.width * 0.5, y: sz.height * 0.38),
radius: sz.width * 0.18,
startAngle: .degrees(200),
endAngle: .degrees(340),
clockwise: false
)
ctx.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.1, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct CheckIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.18, y: sz.height * 0.52))
p.addLine(to: CGPoint(x: sz.width * 0.4, y: sz.height * 0.74))
p.addLine(to: CGPoint(x: sz.width * 0.84, y: sz.height * 0.26))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct WifiIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let c = CGPoint(x: sz.width / 2, y: sz.height * 0.72)
ctx.fill(Path(ellipseIn: CGRect(x: c.x - sz.width * 0.06, y: c.y - sz.width * 0.06, width: sz.width * 0.12, height: sz.width * 0.12)), with: .color(color))
for i in 1...3 {
let r = min(sz.width, sz.height) * (0.16 + CGFloat(i) * 0.16)
var p = Path()
p.addArc(center: c, radius: r, startAngle: .degrees(210), endAngle: .degrees(330), clockwise: false)
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct ChipIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let m = min(sz.width, sz.height) * 0.22
ctx.stroke(
Path(roundedRect: CGRect(x: m, y: m, width: sz.width - 2 * m, height: sz.height - 2 * m), cornerRadius: 6),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
let pin = min(sz.width, sz.height) * 0.08
for i in 0..<3 {
let t = m + (sz.height - 2 * m) * CGFloat(i + 1) / 4
var l = Path(); l.move(to: CGPoint(x: m - pin, y: t)); l.addLine(to: CGPoint(x: m, y: t))
var r = Path(); r.move(to: CGPoint(x: sz.width - m, y: t)); r.addLine(to: CGPoint(x: sz.width - m + pin, y: t))
ctx.stroke(l, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
ctx.stroke(r, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
}
.frame(width: size, height: size)
}
}
struct GermanFlag: View {
var size: CGFloat = 48
var body: some View {
Canvas { ctx, sz in
let band = sz.height / 3
ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
ctx.fill(Path(CGRect(x: 0, y: 0, width: sz.width, height: band)), with: .color(Color(red: 0.18, green: 0.18, blue: 0.19)))
ctx.fill(Path(CGRect(x: 0, y: band, width: sz.width, height: band)), with: .color(Color(red: 0.72, green: 0.27, blue: 0.24)))
ctx.fill(Path(CGRect(x: 0, y: 2 * band, width: sz.width, height: band)), with: .color(Color(red: 0.84, green: 0.70, blue: 0.32)))
}
.frame(width: size, height: size)
}
}
struct CzechFlag: View {
var size: CGFloat = 48
var body: some View {
Canvas { ctx, sz in
ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
ctx.fill(Path(CGRect(origin: .zero, size: sz)), with: .color(Color(white: 0.98)))
ctx.fill(Path(CGRect(x: 0, y: sz.height / 2, width: sz.width, height: sz.height / 2)), with: .color(Color(red: 0.83, green: 0.16, blue: 0.18)))
var tri = Path()
tri.move(to: .zero)
tri.addLine(to: CGPoint(x: sz.width * 0.55, y: sz.height / 2))
tri.addLine(to: CGPoint(x: 0, y: sz.height))
tri.closeSubpath()
ctx.fill(tri, with: .color(Color(red: 0.07, green: 0.24, blue: 0.55)))
}
.frame(width: size, height: size)
}
}
struct BookIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
ctx.stroke(
Path(roundedRect: CGRect(x: sz.width * 0.22, y: sz.height * 0.18, width: sz.width * 0.56, height: sz.height * 0.64), cornerRadius: 4),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
var spine = Path()
spine.move(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.18))
spine.addLine(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.82))
ctx.stroke(spine, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
}
.frame(width: size, height: size)
}
}
struct QuizIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.36
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
let d = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width / 2 - d / 2, y: sz.height * 0.68 - d / 2, width: d, height: d)), with: .color(color))
var stem = Path()
stem.move(to: CGPoint(x: sz.width / 2, y: sz.height * 0.32))
stem.addLine(to: CGPoint(x: sz.width / 2, y: sz.height * 0.52))
ctx.stroke(stem, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct OrderIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
for i in 0..<3 {
let y = sz.height * (0.28 + CGFloat(i) * 0.22)
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.22, y: y))
p.addLine(to: CGPoint(x: sz.width * 0.78, y: y))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct GlobeIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.34
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width * 0.32, y: sz.height * 0.18, width: sz.width * 0.36, height: sz.height * 0.64)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
.frame(width: size, height: size)
}
}
struct PeopleIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.35 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.65 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
var a = Path(); a.addArc(center: CGPoint(x: sz.width * 0.35, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
var b = Path(); b.addArc(center: CGPoint(x: sz.width * 0.65, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
ctx.stroke(a, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
ctx.stroke(b, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct BulbIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.22
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height * 0.4 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.fill(Path(roundedRect: CGRect(x: sz.width * 0.4, y: sz.height * 0.58, width: sz.width * 0.2, height: sz.height * 0.2), cornerRadius: 3), with: .color(color))
}
.frame(width: size, height: size)
}
}
@ViewBuilder
func noteIcon(_ name: String, _ color: Color) -> some View {
switch name {
case "globe": GlobeIcon(color: color)
case "people": PeopleIcon(color: color)
case "bulb": BulbIcon(color: color)
case "quiz": QuizIcon(color: color)
case "order": OrderIcon(color: color)
default: BookIcon(color: color)
}
}

View file

@ -0,0 +1,63 @@
import SwiftUI
struct MapNode {
let id: String
let label: String
let locked: Bool
let done: Bool
let current: Bool
var finish: Bool = false
var onClick: (() -> Void)?
}
struct PathMap: View {
let nodes: [MapNode]
let palette: Palette
var body: some View {
ScrollView {
LazyVStack(spacing: 10) {
ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in
Button {
if !node.locked { node.onClick?() }
} label: {
CardBox(palette: palette) {
HStack(spacing: 12) {
let bg: Color = {
if node.locked { return palette.lockedBg }
if node.current || node.done { return palette.accent }
return palette.node
}()
let fg: Color = {
if node.locked { return palette.overlay }
if node.current || node.done { return palette.onAccent }
return palette.text
}()
ZStack {
Circle().fill(bg).frame(width: 44, height: 44)
if node.locked {
LockIcon(color: palette.overlay, size: 16)
} else if node.done {
CheckIcon(color: palette.onAccent, size: 16)
} else {
Text("\(i + 1)")
.font(.system(size: 16, weight: .heavy))
.foregroundStyle(fg)
}
}
Text(node.label)
.font(.system(size: 16, weight: node.current ? .bold : .semibold))
.foregroundStyle(node.locked ? palette.overlay : palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.buttonStyle(.plain)
.disabled(node.locked || node.onClick == nil)
.opacity(node.locked ? 0.48 : 1)
}
}
.padding(.bottom, 24)
}
}
}

View file

@ -0,0 +1,698 @@
import SwiftUI
struct WelcomeScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("maturita.c")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(p.text)
Text("sestavení \(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
.font(.system(size: 12))
.foregroundStyle(p.overlay)
.padding(.bottom, 12)
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
Text(vm.tr("welcome_title"))
.font(.system(size: 32, weight: .medium))
.foregroundStyle(p.text)
Text(vm.tr("welcome_body_ios"))
.font(.system(size: 16))
.foregroundStyle(p.subtext)
PrimaryButton(label: vm.tr("continue"), palette: p) { vm.go(.subjects) }
}
Spacer(minLength: 0)
VStack {
Text(vm.tr("welcome_stat_value"))
.font(.system(size: 28, weight: .bold))
.foregroundStyle(p.text)
Text(vm.tr("welcome_stat_label"))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(p.subtext)
.multilineTextAlignment(.center)
}
.frame(width: 108, height: 108)
.background(p.mantle, in: Circle())
.overlay(Circle().stroke(p.text.opacity(0.08), lineWidth: 1))
}
FeatureCard(vm: vm, t: "welcome_feat1_title", b: "welcome_feat1_body", l: "welcome_feat1_link")
.padding(.top, 28)
FeatureCard(vm: vm, t: "welcome_feat2_title", b: "welcome_feat2_body", l: "welcome_feat2_link")
.padding(.top, 12)
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
}
}
private struct FeatureCard: View {
@ObservedObject var vm: AppModel
let t, b, l: String
var body: some View {
let p = vm.palette
CardBox(palette: p) {
VStack(alignment: .leading, spacing: 6) {
Text(vm.tr(t)).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr(b)).font(.system(size: 15)).foregroundStyle(p.subtext)
PillButton(label: vm.tr(l), palette: p) { vm.go(.subjects) }
.padding(.top, 4)
}
}
}
}
struct SubjectsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("subjects_title"), subtitle: vm.tr("subjects_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
ForEach(Array(stride(from: 0, to: vm.content.subjects.count, by: 2)), id: \.self) { i in
HStack(spacing: 12) {
subjectTile(vm.content.subjects[i], p)
if i + 1 < vm.content.subjects.count {
subjectTile(vm.content.subjects[i + 1], p)
} else {
Color.clear.frame(maxWidth: .infinity).frame(height: 132)
}
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 24)
}
}
}
private func subjectTile(_ s: J, _ p: Palette) -> some View {
let open = s.bool("open")
return Button {
if open { vm.goPage(s.str("target")) }
} label: {
VStack(spacing: 10) {
switch s.str("icon") {
case "de": GermanFlag(size: 44)
case "wifi": WifiIcon(color: p.text, size: 36)
case "chip": ChipIcon(color: p.text, size: 36)
case "cz": CzechFlag(size: 44)
default: LockIcon(color: p.overlay, size: 22)
}
Text(vm.tr(s.str("key")))
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(p.text)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 132)
.background(p.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(p.text.opacity(open ? 0.08 : 0.04), lineWidth: 1)
)
.opacity(open ? 1 : 0.48)
}
.buttonStyle(.plain)
.disabled(!open)
}
}
struct SettingsSheet: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
ScrollView {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(vm.tr("settings_title"))
.font(.system(size: 22, weight: .bold))
.foregroundStyle(p.text)
Spacer()
PillButton(label: vm.tr("back"), palette: p) { vm.settingsOpen = false }
}
Text(vm.tr("mode")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
HStack(spacing: 4) {
SegChip(label: vm.tr("mode_dark"), selected: vm.mode == .dark, palette: p) { vm.applyMode(.dark) }
SegChip(label: vm.tr("mode_light"), selected: vm.mode == .light, palette: p) { vm.applyMode(.light) }
}
.padding(3)
.background(p.surface1, in: Capsule())
Text(vm.tr("theme")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
ForEach(Array(themeNames.enumerated()), id: \.offset) { i, name in
if let id = ThemeId(rawValue: i) {
let sw = themePalette(id, vm.mode)
let sel = vm.themeId == id
Button { vm.setTheme(id) } label: {
HStack(spacing: 10) {
ZStack {
Circle().fill(sw.base)
Circle().fill(sw.accent).frame(width: 10, height: 10).offset(x: -8)
Circle().fill(sw.accent3).frame(width: 10, height: 10)
Circle().fill(sw.success).frame(width: 10, height: 10).offset(x: 8)
if sel { Circle().stroke(p.accent, lineWidth: 2.2) }
}
.frame(width: 36, height: 36)
Text(name).font(.system(size: 16, weight: .medium)).foregroundStyle(p.text)
Spacer()
}
.padding(10)
.background(sel ? p.surface1 : p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
}
}
Text(vm.tr("language")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
HStack(spacing: 4) {
SegChip(label: "Čeština", selected: vm.lang == .cs, palette: p) { vm.applyLang(.cs) }
SegChip(label: "English", selected: vm.lang == .en, palette: p) { vm.applyLang(.en) }
}
.padding(3)
.background(p.surface1, in: Capsule())
Text(vm.tr("updates")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
Text(updateText)
.font(.system(size: 14))
.foregroundStyle(p.subtext)
HStack(spacing: 8) {
PillButton(label: vm.tr("update_check"), palette: p) { vm.checkUpdate(true) }
if vm.update.canInstall {
PrimaryButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() }
}
}
.padding(.bottom, 24)
}
.padding(20)
}
.background(p.base)
}
private var updateText: String {
if let arg = vm.update.messageArg {
return vm.fmt(vm.update.messageKey, arg)
}
return vm.tr(vm.update.messageKey)
}
}
struct StatsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0 : (100 * sum.doneEx / sum.totalEx)
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("stats_title"), subtitle: vm.tr("stats_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
metric(p, vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
metric(p, vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", pct))
metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
}
ProgressBar(fraction: CGFloat(pct) / 100, palette: p, height: 8)
Text(vm.tr("stats_section"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(p.subtext)
.padding(.top, 10)
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
let open = s.bool("open")
let part = subjectSum(vm, s)
CardBox(palette: p) {
VStack(alignment: .leading, spacing: 4) {
Text(vm.tr(s.str("key"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
.font(.system(size: 13)).foregroundStyle(p.subtext)
if open && part.totalEx > 0 {
ProgressBar(fraction: CGFloat(part.doneEx) / CGFloat(part.totalEx), palette: p, height: 6)
.padding(.top, 4)
}
}
}
.opacity(open ? 1 : 0.55)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 24)
}
}
}
private func metric(_ p: Palette, _ label: String, _ value: String) -> some View {
CardBox(palette: p) {
VStack(alignment: .leading) {
Text(label).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
Text(value).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
}
}
}
}
struct ProgressBar: View {
let fraction: CGFloat
let palette: Palette
var height: CGFloat = 8
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(palette.surface1)
Capsule().fill(palette.accent).frame(width: geo.size.width * min(max(fraction, 0), 1))
}
}
.frame(height: height)
}
}
private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum {
var sum = ProgressSum()
switch s.str("target") {
case "roadmap":
for u in vm.content.german where u.bool("unlocked") {
let n = u.strs("names").count
sum.totalEx += n
sum.doneEx += (1...n).filter { vm.progress.germanDone(u.int("id"), $0) }.count
}
case "netyears":
sum.totalEx = vm.content.netLessons.count
sum.doneEx = vm.content.netLessons.filter { vm.progress.netDone($0.int("id")) }.count
case "hwmap":
sum.totalEx = vm.content.hw.count
sum.doneEx = vm.content.hw.filter { vm.progress.hwDone($0.int("id")) }.count
case "czechmap":
sum.totalEx = vm.content.mluvnice.count
sum.doneEx = vm.content.mluvnice.filter { vm.progress.mluvDone($0.int("id")) }.count
default:
break
}
return sum
}
struct SearchOverlay: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
let q = normalizeAnswer(vm.searchQuery)
let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }.prefix(24)
VStack(alignment: .leading, spacing: 12) {
HStack {
ZStack(alignment: .leading) {
if vm.searchQuery.isEmpty {
Text(vm.tr("search_placeholder")).foregroundStyle(p.overlay)
}
TextField("", text: $vm.searchQuery)
.foregroundStyle(p.text)
.tint(p.accent)
}
.padding(14)
.background(p.mantle, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
PillButton(label: vm.tr("back"), palette: p) { vm.searchOpen = false }
}
if hits.isEmpty {
Text(q.isEmpty ? vm.tr("search_hint") : vm.tr("search_empty")).foregroundStyle(p.subtext)
} else {
ScrollView {
VStack(spacing: 8) {
ForEach(Array(hits), id: \.target) { hit in
Button {
if !hit.locked {
vm.goPage(hit.target)
vm.searchOpen = false
}
} label: {
CardBox(palette: p) {
VStack(alignment: .leading) {
Text(hit.title).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text)
Text(hit.sub).font(.system(size: 13)).foregroundStyle(p.subtext)
}
}
}
.buttonStyle(.plain)
.opacity(hit.locked ? 0.5 : 1)
}
}
}
}
Spacer(minLength: 0)
}
.padding(16)
.background(p.base.opacity(0.96))
}
}
private struct Hit: Identifiable {
var id: String { target + title }
let title: String
let sub: String
let target: String
let locked: Bool
let hay: String
}
private func buildSearch(_ vm: AppModel) -> [Hit] {
var out: [Hit] = []
func add(_ title: String, _ sub: String, _ target: String, locked: Bool = false, extra: String = "") {
out.append(Hit(title: title, sub: sub, target: target, locked: locked, hay: "\(title) \(sub) \(target) \(extra)"))
}
add(vm.tr("subjects_title"), vm.tr("search_page"), "subjects", extra: "predmety")
add(vm.tr("stats"), vm.tr("search_page"), "stats")
add(vm.tr("search_home"), vm.tr("search_page"), "welcome")
for s in vm.content.subjects {
add(vm.tr(s.str("key")), vm.tr("search_subject"), s.strOrNull("target") ?? "subjects", locked: !s.bool("open"))
}
for u in vm.content.german {
let unlocked = u.bool("unlocked")
add(u.str("title"), vm.tr("search_unit"), u.strOrNull("page") ?? "roadmap", locked: !unlocked, extra: "deutsch")
if unlocked {
for (i, name) in u.strs("names").enumerated() {
add(name, u.str("title"), "u\(u.int("id") + 1)e\(i + 1)")
}
if u.has("branch") { add("Vokabeltraining", u.str("title"), u.str("branch"), extra: "vokabel") }
}
}
for l in vm.content.netLessons {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "netunit\(l.int("id"))", extra: "site")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "netex\(l.int("id"))")
}
for l in vm.content.hw {
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "hwunit\(l.int("id"))", extra: "hardware")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "hwex\(l.int("id"))")
}
add(vm.tr("Mluvnice"), vm.tr("search_lesson"), "mluvnice")
add(vm.tr("Maturitní četba"), vm.tr("search_book"), "readinglist")
for b in vm.content.books {
add(b.str("title"), vm.tr("search_book"), b.str("page"))
}
return out
}
struct RoadmapScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let current = vm.content.german.firstIndex { u in
u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) }
}
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("roadmap_title"), subtitle: vm.tr("roadmap_sub"))
.padding(.horizontal, 12)
PathMap(
nodes: vm.content.german.enumerated().map { i, u in
let unlocked = u.bool("unlocked")
let names = u.strs("names")
let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) }
return MapNode(
id: u.str("page"),
label: u.str("title"),
locked: !unlocked,
done: done,
current: i == current,
finish: i == vm.content.german.count - 1,
onClick: unlocked ? { vm.go(.unitMap(u.int("id"))) } : nil
)
},
palette: vm.palette
)
.padding(.horizontal, 12)
}
}
}
struct UnitMapScreen: View {
@ObservedObject var vm: AppModel
let unitId: Int
var body: some View {
if let u = vm.content.germanUnit(unitId) {
let names = u.strs("names")
let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) }
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: u.str("title"), subtitle: vm.tr(u.str("sub")))
.padding(.horizontal, 12)
PathMap(
nodes: names.enumerated().map { i, name in
MapNode(
id: "e\(i + 1)",
label: name,
locked: false,
done: vm.progress.germanDone(unitId, i + 1),
current: i == next,
onClick: { vm.go(.germanEx(unitId, i + 1)) }
)
},
palette: vm.palette
)
.padding(.horizontal, 12)
if u.has("branch") {
PrimaryButton(label: vm.tr("Vokabeltraining"), palette: vm.palette) { vm.go(.vocab(unitId)) }
.padding(.bottom, 16)
}
}
}
}
}
struct NetYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_years_title"), subtitle: vm.tr("net_years_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
yearCard(vm.tr("net_year1"), vm.tr("net_sub"), true) { vm.go(.netMap) }
ForEach(["net_year2", "net_year3", "net_year4"], id: \.self) { key in
yearCard(vm.tr(key), vm.tr("net_year_locked_sub"), false) {}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
}
}
private func yearCard(_ title: String, _ sub: String, _ open: Bool, _ action: @escaping () -> Void) -> some View {
let p = vm.palette
return Button(action: action) {
CardBox(palette: p) {
HStack {
VStack(alignment: .leading) {
Text(title).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text)
Text(sub).font(.system(size: 14)).foregroundStyle(p.subtext)
}
Spacer()
if !open { LockIcon(color: p.overlay) }
}
}
}
.buttonStyle(.plain)
.disabled(!open)
.opacity(open ? 1 : 0.48)
}
}
struct LessonMapScreen: View {
@ObservedObject var vm: AppModel
let title: String
let sub: String
let nodes: [MapNode]
var body: some View {
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
.padding(.horizontal, 12)
PathMap(nodes: nodes, palette: vm.palette)
.padding(.horizontal, 12)
}
}
}
struct SlidesScreen: View {
@ObservedObject var vm: AppModel
let title: String
let sub: String
let slides: [J]
var onExercise: () -> Void
@State private var idx = 0
var body: some View {
let p = vm.palette
if let slide = slides.indices.contains(idx) ? slides[idx] : nil {
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
.padding(.horizontal, 16)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text(slide.str("kicker")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
Text(slide.str("title")).font(.system(size: 24, weight: .bold)).foregroundStyle(p.text)
if let tip = slide.strOrNull("tip") {
CardBox(palette: p) { Text(tip).foregroundStyle(p.text) }
}
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
CardBox(palette: p) { Text(line).font(.system(size: 16)).foregroundStyle(p.text) }
}
}
.padding(.horizontal, 16)
}
HStack {
if idx > 0 {
PillButton(label: vm.tr("net_slide_prev"), palette: p) { idx -= 1 }
}
Spacer()
if idx < slides.count - 1 {
PrimaryButton(label: vm.tr("net_slide_next"), palette: p) { idx += 1 }
} else {
PrimaryButton(label: vm.tr("net_slide_start"), palette: p, action: onExercise)
}
}
.padding(12)
}
}
}
}
struct CzechMapScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
let items: [(String, String, Route?)] = [
("L", vm.tr("Literatura"), nil),
("M", vm.tr("Mluvnice"), .mluvnice),
("Č", vm.tr("Maturitní četba"), .readingList),
]
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Český jazyk a literatura"), subtitle: vm.tr("czech_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
ForEach(items, id: \.0) { letter, name, route in
Button {
if let route { vm.go(route) }
} label: {
CardBox(palette: p) {
HStack(spacing: 14) {
ZStack {
Circle().fill(route == nil ? p.lockedBg : p.accent).frame(width: 56, height: 56)
if route == nil {
LockIcon(color: p.overlay)
} else {
Text(letter).font(.system(size: 20, weight: .heavy)).foregroundStyle(p.onAccent)
}
}
Text(name).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text)
Spacer()
}
}
}
.buttonStyle(.plain)
.disabled(route == nil)
.opacity(route == nil ? 0.48 : 1)
}
}
.padding(.horizontal, 16)
}
}
}
}
struct BookListScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let p = vm.palette
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub"))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
Button { vm.go(.book(b.str("id"))) } label: {
CardBox(palette: p) {
HStack(spacing: 12) {
BookIcon(color: p.text, size: 28)
VStack(alignment: .leading) {
Text(b.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr(b.str("subKey"))).font(.system(size: 13)).foregroundStyle(p.subtext)
}
Spacer()
}
}
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
}
}
}
}
struct BookScreen: View {
@ObservedObject var vm: AppModel
let id: String
var body: some View {
if let b = vm.content.book(id) {
let p = vm.palette
VStack(spacing: 0) {
PageTop(vm: vm, back: { vm.back() }, title: b.str("title"), subtitle: vm.tr(b.str("subKey")))
.padding(.horizontal, 16)
ScrollView {
VStack(spacing: 12) {
Button { vm.go(.bookQuiz(id)) } label: {
CardBox(palette: p) {
HStack(spacing: 10) {
QuizIcon(color: p.text)
VStack(alignment: .leading) {
Text(vm.tr(b.str("quizTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr(b.str("quizSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
}
}
}
}
.buttonStyle(.plain)
Button { vm.go(.bookPlot(id)) } label: {
CardBox(palette: p) {
HStack(spacing: 10) {
OrderIcon(color: p.text)
VStack(alignment: .leading) {
Text(vm.tr(b.str("plotTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr(b.str("plotSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
}
}
}
}
.buttonStyle(.plain)
ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in
CardBox(palette: p) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
noteIcon(note.str("icon"), p.text)
Text(note.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
}
ForEach(note.strs("lines"), id: \.self) { line in
Text("• \(line)").font(.system(size: 15)).foregroundStyle(p.text)
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 20)
}
}
}
}
}

View file

@ -0,0 +1,113 @@
import Foundation
import UIKit
struct UpdateState {
var status = "idle"
var remote: String?
var messageKey = "update_current"
var messageArg: String? = String(AppConfig.commit.prefix(7))
var canInstall = false
}
final class Updater {
private let repo = AppConfig.updateRepo
private let branch = AppConfig.updateBranch
func check(interactive: Bool, cb: @escaping (UpdateState) -> Void) {
if AppConfig.commit == "dev" {
emit(cb, UpdateState(status: "dev", messageKey: "update_err_devbuild", messageArg: nil))
return
}
emit(cb, UpdateState(status: "checking", messageKey: "update_checking", messageArg: nil))
DispatchQueue.global(qos: .utility).async {
do {
let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
let remote: String
if let sha = self.atomMainSha(atom) {
remote = sha
} else if let tip = self.atomTip(atom) {
remote = try self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION").trimmingCharacters(in: .whitespacesAndNewlines)
} else {
throw URLError(.cannotParseResponse)
}
let local = AppConfig.commit
if self.sameCommit(remote, local) {
self.emit(cb, UpdateState(status: "ok", remote: remote, messageKey: "update_uptodate", messageArg: String(remote.prefix(7))))
} else {
self.emit(cb, UpdateState(status: "available", remote: remote, messageKey: "update_available", messageArg: String(remote.prefix(7)), canInstall: true))
}
} catch {
self.emit(cb, UpdateState(status: "error", messageKey: "update_err_network", messageArg: nil))
}
}
}
func install(cb: @escaping (UpdateState) -> Void) {
emit(cb, UpdateState(status: "downloading", messageKey: "update_downloading", messageArg: nil))
DispatchQueue.global(qos: .utility).async {
do {
let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
let tip = self.atomTip(atom) ?? ""
let page = "https://github.com/\(self.repo)/raw/main/installers/maturita-ios.html"
DispatchQueue.main.async {
if let url = URL(string: page) {
UIApplication.shared.open(url)
}
}
self.emit(cb, UpdateState(status: "staged", remote: tip, messageKey: "update_ios_sideload", messageArg: String(tip.prefix(7))))
} catch {
self.emit(cb, UpdateState(status: "error", messageKey: "update_err_download", messageArg: nil))
}
}
}
private func emit(_ cb: @escaping (UpdateState) -> Void, _ state: UpdateState) {
DispatchQueue.main.async { cb(state) }
}
private func atomTip(_ atom: String) -> String? {
if let m = atom.range(of: #"Commit/([0-9a-f]{40})"#, options: [.regularExpression, .caseInsensitive]) {
let s = String(atom[m])
return s.split(separator: "/").last.map(String.init)
}
if let m = atom.range(of: #"/commit/([0-9a-f]{7,40})"#, options: [.regularExpression, .caseInsensitive]) {
return String(atom[m]).split(separator: "/").last.map(String.init)
}
return nil
}
private func atomMainSha(_ atom: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: #"Build from ([0-9a-f]{7,40})"#, options: .caseInsensitive),
let match = regex.firstMatch(in: atom, range: NSRange(atom.startIndex..., in: atom)),
let range = Range(match.range(at: 1), in: atom)
else { return nil }
return String(atom[range])
}
private func sameCommit(_ a: String, _ b: String) -> Bool {
let n = min(a.count, b.count)
return n >= 7 && a.prefix(n).lowercased() == b.prefix(n).lowercased()
}
private func get(_ url: String) throws -> String {
guard let u = URL(string: url) else { throw URLError(.badURL) }
var req = URLRequest(url: u, timeoutInterval: 15)
req.setValue("maturita.c-ios", forHTTPHeaderField: "User-Agent")
req.setValue("*/*", forHTTPHeaderField: "Accept")
let sem = DispatchSemaphore(value: 0)
var result: Result<String, Error> = .failure(URLError(.unknown))
URLSession.shared.dataTask(with: req) { data, resp, err in
if let err { result = .failure(err) }
else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
result = .failure(URLError(.badServerResponse))
} else if let data, let text = String(data: data, encoding: .utf8) {
result = .success(text)
} else {
result = .failure(URLError(.cannotDecodeContentData))
}
sem.signal()
}.resume()
_ = sem.wait(timeout: .now() + 20)
return try result.get()
}
}