diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 57df86c..80665a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -143,9 +143,51 @@ jobs: name: maturita-macos path: dist/maturita-macos-*.zip + android: + name: Android APK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - uses: android-actions/setup-android@v3 + + - name: Install Android SDK platform + run: | + sdkmanager --install "platforms;android-37" "build-tools;36.0.0" + if [ -d "$ANDROID_HOME/platforms/android-37.0" ] && [ ! -e "$ANDROID_HOME/platforms/android-37" ]; then + ln -s android-37.0 "$ANDROID_HOME/platforms/android-37" + fi + + - uses: gradle/actions/setup-gradle@v4 + with: + build-root-directory: android + + - name: Extract content and build APK + env: + COMMIT: ${{ github.sha }} + run: | + python3 scripts/extract-android-content.py + chmod +x android/gradlew + cd android + ./gradlew :app:assembleRelease --no-daemon + mkdir -p ../dist-android + cp app/build/outputs/apk/release/app-release.apk \ + ../dist-android/maturita-android.apk + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: maturita-android + path: dist-android/maturita-android.apk + publish: name: Publish to builds branch - needs: [linux, windows, macos] + needs: [linux, windows, macos, android] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 9e12c97..fd0137f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,14 @@ cmake-build-*/ error.txt error.log CLAUDE.md + +# Android +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties +android/.idea/ +android/*.iml +android/.cxx/ +android/.local-sdk/ +android/app/src/main/assets/content.json diff --git a/Makefile b/Makefile index 6d900a5..b911362 100644 --- a/Makefile +++ b/Makefile @@ -115,4 +115,8 @@ installer-zips: -include $(DEP) -.PHONY: all clean run bundle installer-zips +android: + python3 scripts/extract-android-content.py + cd android && ./gradlew :app:assembleRelease + +.PHONY: all clean run bundle installer-zips android diff --git a/README.md b/README.md index e5d3d99..9f2690c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ větve. | macOS (Apple Silicon) | [maturita-installer-macos.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-macos.zip) | Rozbalit, pravý klik na **Nainstalovat maturita.C** → **Otevřít** | | Windows (x64) | [maturita-installer-windows.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-windows.zip) | Rozbalit a dvojklik; u SmartScreenu **Další informace → Přesto spustit** | | Linux (x86_64) | [maturita-installer-linux.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-linux.zip) | Rozbalit a dvojklik na instalátor | +| Android (8+) | [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-android.apk) | Stáhnout APK a nainstalovat (neznámé zdroje) | Kam se to nainstaluje: `/Applications` nebo `~/Applications` (macOS), `%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux). @@ -49,8 +50,10 @@ spolehlivější instalátor výše, nebo stažení přímo z té stránky větv - [maturita-macos-arm64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-macos-arm64.zip) - [maturita-windows-x64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-windows-x64.zip) - [maturita-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-linux-x86_64.AppImage) +- [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-android.apk) Postup a nastavení se ukládají vedle `.app` / složky / AppImage do `progress/`. +Na Androidu do úložiště aplikace (stejné statistiky, témata a jazyk). Intel Mac, jiná architektura nebo úpravy kódu → [sestavení ze zdroje](#sestavení-ze-zdroje). @@ -129,14 +132,26 @@ make bundle # dist/maturita.exe jde spustit i z Průzkumník ``` src/ C zdroje a maturita.h +android/ Jetpack Compose přehrávač (stejný obsah) data/ style.css, share/, podpisy, Windows .rc assets/ zdrojová ikona a screenshoty installers/ instalátory, které stáhnou aktuální build -scripts/ balení AppImage a .app +scripts/ balení AppImage, .app a extract-android-content.py .github/workflows/ CI: push na main → větev builds progress/ vzniká za běhu (cvičení, nastavení) ``` +Android ze zdroje (JDK 17 + Android SDK): + +```bash +python3 scripts/extract-android-content.py +cd android +./gradlew :app:assembleRelease +# APK: android/app/build/outputs/apk/release/app-release.apk +``` + +nebo z kořene `make android`. + Licence: [GPL-3.0](LICENSE). --- @@ -165,6 +180,7 @@ branch-named file. | macOS (Apple Silicon) | [maturita-installer-macos.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-macos.zip) | Unzip, right-click **Nainstalovat maturita.C** → **Open** | | Windows (x64) | [maturita-installer-windows.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-windows.zip) | Unzip and double-click; if SmartScreen appears, **More info → Run anyway** | | Linux (x86_64) | [maturita-installer-linux.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-linux.zip) | Unzip and double-click the installer | +| Android (8+) | [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-android.apk) | Download the APK and install (unknown sources) | Install locations: `/Applications` or `~/Applications` (macOS), `%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux). @@ -184,9 +200,10 @@ above, or download from that branch page: - [maturita-macos-arm64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-macos-arm64.zip) - [maturita-windows-x64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-windows-x64.zip) - [maturita-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-linux-x86_64.AppImage) +- [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-android.apk) Progress and settings live in `progress/` next to the `.app` / folder / -AppImage. +AppImage. On Android they stay in app storage (same stats, themes, language). Intel Mac, another architecture, or hacking on the code → [build from source](#build-from-source). @@ -266,12 +283,24 @@ make bundle # dist/maturita.exe can be double-clicked ``` src/ C sources and maturita.h +android/ Jetpack Compose player (same content) data/ style.css, share/, signing files, Windows .rc assets/ source icon and screenshots installers/ fetch-the-latest installers -scripts/ AppImage / .app bundlers +scripts/ AppImage / .app bundlers and extract-android-content.py .github/workflows/ CI: push to main → builds branch progress/ created at runtime (exercises, settings) ``` +Android from source (JDK 17 + Android SDK): + +```bash +python3 scripts/extract-android-content.py +cd android +./gradlew :app:assembleRelease +# APK: android/app/build/outputs/apk/release/app-release.apk +``` + +or `make android` from the repo root. + License: [GPL-3.0](LICENSE). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..77a4644 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") +} + +val commit: String = System.getenv("COMMIT")?.takeIf { it.isNotBlank() } ?: "dev" + +android { + namespace = "org.maturita.maturita" + compileSdk = 37 + + defaultConfig { + applicationId = "org.maturita.maturita" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "android" + buildConfigField("String", "COMMIT", "\"$commit\"") + buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"") + buildConfigField("String", "UPDATE_BRANCH", "\"builds\"") + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + buildConfig = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + debugImplementation("androidx.compose.ui:ui-tooling") +} + +val extractContent = tasks.register("extractContent") { + workingDir = rootProject.projectDir.parentFile + commandLine("python3", "scripts/extract-android-content.py") + inputs.dir(rootProject.projectDir.parentFile.resolve("src")) + outputs.file(file("src/main/assets/content.json")) +} + +tasks.named("preBuild").configure { + dependsOn(extractContent) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..c7d9f0b --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# Debug-signed student builds keep the full content JSON. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..56d6018 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/org/maturita/maturita/AppViewModel.kt b/android/app/src/main/java/org/maturita/maturita/AppViewModel.kt new file mode 100644 index 0000000..3148fa6 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/AppViewModel.kt @@ -0,0 +1,120 @@ +package org.maturita.maturita + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import org.maturita.maturita.data.ColorMode +import org.maturita.maturita.data.Content +import org.maturita.maturita.data.ProgressStore +import org.maturita.maturita.data.ThemeId +import org.maturita.maturita.data.UiLang +import org.maturita.maturita.data.themePalette +import org.maturita.maturita.update.UpdateState +import org.maturita.maturita.update.Updater + +class AppViewModel(app: Application) : AndroidViewModel(app) { + val content = Content.load(app) + val progress = ProgressStore(app) + val updater = Updater(app) + + var themeId by mutableStateOf(progress.themeId) + private set + var mode by mutableStateOf(progress.mode) + private set + var lang by mutableStateOf(progress.lang) + private set + var settingsOpen by mutableStateOf(false) + var searchOpen by mutableStateOf(false) + var searchQuery by mutableStateOf("") + var tick by mutableStateOf(0) + private set + var update by mutableStateOf(UpdateState()) + private set + + private val stack = mutableStateListOf(Route.Welcome) + val route: Route get() = stack.last() + val canGoBack: Boolean get() = stack.size > 1 + + val palette get() = themePalette(themeId, mode) + + fun tr(key: String?) = content.tr(key, lang) + fun fmt(key: String, vararg args: Any) = content.fmt(key, lang, *args) + + fun go(r: Route) { + if (stack.last() == r) return + stack.add(r) + searchOpen = false + } + + fun goPage(page: String?) { + val r = page?.let { routeFromPage(it) } ?: return + go(r) + } + + fun back() { + if (stack.size > 1) stack.removeAt(stack.lastIndex) + } + + fun setTheme(id: ThemeId) { + themeId = id + progress.themeId = id + } + + fun applyMode(m: ColorMode) { + mode = m + progress.mode = m + } + + fun applyLang(l: UiLang) { + lang = l + progress.lang = l + } + + fun refresh() { tick++ } + + fun markGerman(unit: Int, ex: Int) { + progress.markGerman(unit, ex) + refresh() + } + + fun markVocab(unit: Int) { + progress.markVocab(unit) + refresh() + } + + fun markNet(id: Int) { + progress.markNet(id) + refresh() + } + + fun markHw(id: Int) { + progress.markHw(id) + refresh() + } + + fun markMluv(n: Int) { + progress.markMluv(n) + refresh() + } + + fun markBookQuiz(id: String) { + progress.markBookQuiz(id) + refresh() + } + + fun markBookPlot(id: String) { + progress.markBookPlot(id) + refresh() + } + + fun checkUpdate(interactive: Boolean) { + updater.check(interactive) { update = it } + } + + fun installUpdate() { + updater.install { update = it } + } +} diff --git a/android/app/src/main/java/org/maturita/maturita/MainActivity.kt b/android/app/src/main/java/org/maturita/maturita/MainActivity.kt new file mode 100644 index 0000000..b0836e1 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/MainActivity.kt @@ -0,0 +1,195 @@ +package org.maturita.maturita + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.maturita.maturita.ui.BookListScreen +import org.maturita.maturita.ui.BookScreen +import org.maturita.maturita.ui.CzechMapScreen +import org.maturita.maturita.ui.GermanExercise +import org.maturita.maturita.ui.HwQuizScreen +import org.maturita.maturita.ui.IconHit +import org.maturita.maturita.ui.LessonMapScreen +import org.maturita.maturita.ui.LitQuizScreen +import org.maturita.maturita.ui.MapNode +import org.maturita.maturita.ui.MluvExercise +import org.maturita.maturita.ui.NetQuizScreen +import org.maturita.maturita.ui.NetYearsScreen +import org.maturita.maturita.ui.PlotScreen +import org.maturita.maturita.ui.RoadmapScreen +import org.maturita.maturita.ui.SearchIcon +import org.maturita.maturita.ui.SearchOverlay +import org.maturita.maturita.ui.SettingsIcon +import org.maturita.maturita.ui.SettingsSheet +import org.maturita.maturita.ui.SlidesScreen +import org.maturita.maturita.ui.StatsIcon +import org.maturita.maturita.ui.StatsScreen +import org.maturita.maturita.ui.SubjectsScreen +import org.maturita.maturita.ui.UnitMapScreen +import org.maturita.maturita.ui.VocabExercise +import org.maturita.maturita.ui.WelcomeScreen + +class MainActivity : ComponentActivity() { + private val vm: AppViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { MaturitaApp(vm) } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MaturitaApp(vm: AppViewModel) { + val p = vm.palette + LaunchedEffect(Unit) { vm.checkUpdate(false) } + BackHandler(vm.canGoBack || vm.searchOpen || vm.settingsOpen) { + when { + vm.searchOpen -> vm.searchOpen = false + vm.settingsOpen -> vm.settingsOpen = false + else -> vm.back() + } + } + Box( + Modifier + .fillMaxSize() + .background(p.base) + .statusBarsPadding() + .navigationBarsPadding(), + ) { + Column(Modifier.fillMaxSize()) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("maturita.c", color = p.text, fontWeight = FontWeight.Bold, fontSize = 15.sp, modifier = Modifier.weight(1f)) + IconHit({ vm.searchOpen = true }) { SearchIcon(p.text) } + IconHit({ vm.go(Route.Stats) }) { StatsIcon(p.text) } + IconHit({ vm.settingsOpen = true }) { SettingsIcon(p.text) } + } + if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(14.dp)) + .background(p.surface1) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())), + color = p.text, + fontSize = 13.sp, + modifier = Modifier.weight(1f), + ) + if (vm.update.canInstall) { + org.maturita.maturita.ui.PillButton(vm.tr("update_install"), p) { vm.installUpdate() } + } + } + } + Box(Modifier.weight(1f)) { + when (val r = vm.route) { + Route.Welcome -> WelcomeScreen(vm) + Route.Subjects -> SubjectsScreen(vm) + Route.Stats -> StatsScreen(vm) + Route.Roadmap -> RoadmapScreen(vm) + is Route.UnitMap -> UnitMapScreen(vm, r.unitId) + is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex) + is Route.Vocab -> VocabExercise(vm, r.unitId) + Route.NetYears -> NetYearsScreen(vm) + Route.NetMap -> LessonMapScreen( + vm, vm.tr("net_year1"), vm.tr("net_sub"), + vm.content.netLessons.map { l -> + val id = l.int("id") + MapNode( + "n$id", vm.tr(l.str("titleKey")), false, + vm.progress.netDone(id), !vm.progress.netDone(id), + ) { vm.go(Route.NetLesson(id)) } + }, + vm.content.netLessons.count { vm.progress.netDone(it.int("id")) }.toFloat(), + ) + is Route.NetLesson -> { + val l = vm.content.netLesson(r.id) + if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) { + vm.go(Route.NetEx(r.id)) + } + } + is Route.NetEx -> NetQuizScreen(vm, r.id) + Route.HwMap -> LessonMapScreen( + vm, vm.tr("Technické vybavení"), vm.tr("hw_sub"), + vm.content.hw.map { l -> + val id = l.int("id") + MapNode( + "h$id", vm.tr(l.str("titleKey")), false, + vm.progress.hwDone(id), !vm.progress.hwDone(id), + ) { vm.go(Route.HwLesson(id)) } + }, + vm.content.hw.count { vm.progress.hwDone(it.int("id")) }.toFloat(), + ) + is Route.HwLesson -> { + val l = vm.content.hwLesson(r.id) + if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) { + vm.go(Route.HwEx(r.id)) + } + } + is Route.HwEx -> HwQuizScreen(vm, r.id) + Route.CzechMap -> CzechMapScreen(vm) + Route.Mluvnice -> LessonMapScreen( + vm, vm.tr("Mluvnice"), "Cvičení z mluvnice – styl maturita / přijímačky z ČJL", + vm.content.mluvnice.map { m -> + val n = m.int("id") + MapNode( + "m$n", m.str("name"), false, + vm.progress.mluvDone(n), !vm.progress.mluvDone(n), + ) { vm.go(Route.MluvEx(n)) } + }, + vm.content.mluvnice.count { vm.progress.mluvDone(it.int("id")) }.toFloat(), + ) + is Route.MluvEx -> MluvExercise(vm, r.n) + Route.ReadingList -> BookListScreen(vm) + is Route.Book -> BookScreen(vm, r.id) + is Route.BookQuiz -> LitQuizScreen(vm, r.id) + is Route.BookPlot -> PlotScreen(vm, r.id) + } + } + } + if (vm.searchOpen) SearchOverlay(vm) + if (vm.settingsOpen) { + ModalBottomSheet( + onDismissRequest = { vm.settingsOpen = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = p.base, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + ) { SettingsSheet(vm) } + } + } +} diff --git a/android/app/src/main/java/org/maturita/maturita/Route.kt b/android/app/src/main/java/org/maturita/maturita/Route.kt new file mode 100644 index 0000000..55203d4 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/Route.kt @@ -0,0 +1,71 @@ +package org.maturita.maturita + +sealed class Route(val page: String) { + data object Welcome : Route("welcome") + data object Subjects : Route("subjects") + data object Stats : Route("stats") + data object Roadmap : Route("roadmap") + data class UnitMap(val unitId: Int) : Route("unit${unitId + 1}") + data class GermanEx(val unitId: Int, val ex: Int) : Route("u${unitId + 1}e$ex") + data class Vocab(val unitId: Int) : Route("u${unitId + 1}vocab") + data object NetYears : Route("netyears") + data object NetMap : Route("netmap") + data class NetLesson(val id: Int) : Route("netunit$id") + data class NetEx(val id: Int) : Route("netex$id") + data object HwMap : Route("hwmap") + data class HwLesson(val id: Int) : Route("hwunit$id") + data class HwEx(val id: Int) : Route("hwex$id") + data object CzechMap : Route("czechmap") + data object Mluvnice : Route("mluvnice") + data class MluvEx(val n: Int) : Route("mluve$n") + data object ReadingList : Route("readinglist") + data class Book(val id: String) : Route(if (id == "1984") "cetba1984" else "cetbaFuks") + data class BookQuiz(val id: String) : Route(if (id == "1984") "cetba1984quiz" else "cetbaFuksQuiz") + data class BookPlot(val id: String) : Route(if (id == "1984") "cetba1984dej" else "cetbaFuksDej") +} + +fun routeFromPage(page: String): Route? = when (page) { + "welcome" -> Route.Welcome + "subjects" -> Route.Subjects + "stats" -> Route.Stats + "roadmap" -> Route.Roadmap + "netyears" -> Route.NetYears + "netmap" -> Route.NetMap + "hwmap" -> Route.HwMap + "czechmap" -> Route.CzechMap + "mluvnice" -> Route.Mluvnice + "readinglist" -> Route.ReadingList + "cetba1984" -> Route.Book("1984") + "cetba1984quiz" -> Route.BookQuiz("1984") + "cetba1984dej" -> Route.BookPlot("1984") + "cetbaFuks" -> Route.Book("fuks") + "cetbaFuksQuiz" -> Route.BookQuiz("fuks") + "cetbaFuksDej" -> Route.BookPlot("fuks") + else -> { + Regex("""unit(\d+)""").matchEntire(page)?.let { + return Route.UnitMap(it.groupValues[1].toInt() - 1) + } + Regex("""u(\d+)e(\d+)""").matchEntire(page)?.let { + return Route.GermanEx(it.groupValues[1].toInt() - 1, it.groupValues[2].toInt()) + } + Regex("""u(\d+)vocab""").matchEntire(page)?.let { + return Route.Vocab(it.groupValues[1].toInt() - 1) + } + Regex("""netunit(\d+)""").matchEntire(page)?.let { + return Route.NetLesson(it.groupValues[1].toInt()) + } + Regex("""netex(\d+)""").matchEntire(page)?.let { + return Route.NetEx(it.groupValues[1].toInt()) + } + Regex("""hwunit(\d+)""").matchEntire(page)?.let { + return Route.HwLesson(it.groupValues[1].toInt()) + } + Regex("""hwex(\d+)""").matchEntire(page)?.let { + return Route.HwEx(it.groupValues[1].toInt()) + } + Regex("""mluve(\d+)""").matchEntire(page)?.let { + return Route.MluvEx(it.groupValues[1].toInt()) + } + null + } +} diff --git a/android/app/src/main/java/org/maturita/maturita/data/Answers.kt b/android/app/src/main/java/org/maturita/maturita/data/Answers.kt new file mode 100644 index 0000000..6104b1f --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/data/Answers.kt @@ -0,0 +1,65 @@ +package org.maturita.maturita.data + +import java.text.Normalizer + +fun normalizeAnswer(input: String?): String { + val src = (input ?: "").lowercase() + val pre = StringBuilder() + src.codePoints().forEach { ch -> + when (ch) { + 0x00e4 -> pre.append("ae") + 0x00f6 -> pre.append("oe") + 0x00fc -> pre.append("ue") + 0x00df -> pre.append("ss") + else -> pre.appendCodePoint(ch) + } + } + val decomp = Normalizer.normalize(pre.toString(), Normalizer.Form.NFD) + val out = StringBuilder() + var prevSpace = false + decomp.codePoints().forEach { ch -> + val type = Character.getType(ch) + if (type == Character.NON_SPACING_MARK.toInt() || + type == Character.COMBINING_SPACING_MARK.toInt() || + type == Character.ENCLOSING_MARK.toInt() + ) { + return@forEach + } + if (Character.isWhitespace(ch)) { + if (out.isNotEmpty() && !prevSpace) out.append(' ') + prevSpace = true + return@forEach + } + if (!Character.isLetterOrDigit(ch)) return@forEach + prevSpace = false + out.appendCodePoint(ch) + } + return out.toString().trimEnd() +} + +fun answerAccepts(selNorm: String, ans: String?): Boolean { + if (ans.isNullOrEmpty()) return false + return ans.split('|').any { normalizeAnswer(it) == selNorm } +} + +fun answerIncomplete(selNorm: String, ans: String?): Boolean { + if (selNorm.isEmpty() || ans.isNullOrEmpty()) return false + for (part in ans.split('|')) { + val a = normalizeAnswer(part) + if (a.startsWith("$selNorm ") && a.length > selNorm.length + 1) return true + } + return false +} + +fun netTxtEq(a: String?, b: String?): Boolean { + if (a == null || b == null) return a == b + val x = a.filterNot { it.isWhitespace() }.lowercase() + val y = b.filterNot { it.isWhitespace() }.lowercase() + return x == y +} + +fun hasUmlaut(s: String?): Boolean { + if (s == null) return false + return s.any { it == 'ä' || it == 'ö' || it == 'ü' || it == 'ß' || + it == 'Ä' || it == 'Ö' || it == 'Ü' } +} diff --git a/android/app/src/main/java/org/maturita/maturita/data/Content.kt b/android/app/src/main/java/org/maturita/maturita/data/Content.kt new file mode 100644 index 0000000..10a3d75 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/data/Content.kt @@ -0,0 +1,66 @@ +package org.maturita.maturita.data + +import android.content.Context +import org.json.JSONObject + +class Content(root: JSONObject) { + val raw = J(root) + val i18n: Map> = run { + val obj = root.optJSONObject("i18n") ?: JSONObject() + obj.keys().asSequence().associateWith { key -> + val e = obj.optJSONObject(key) + val cs = e?.optString("cs")?.takeIf { it.isNotEmpty() && it != "null" } + val en = e?.optString("en")?.takeIf { it.isNotEmpty() && it != "null" } + cs to en + } + } + val subjects = raw.arr("subjects") + val german = raw.arr("german") + val net = raw.obj("net")!! + val netLessons = net.arr("lessons") + val netTasks = net.arr("tasks") + val netAnswers: List> = run { + val a = net.o.optJSONArray("answers") ?: return@run emptyList() + (0 until a.length()).map { i -> + val block = a.optJSONArray(i) ?: org.json.JSONArray() + (0 until block.length()).mapNotNull { j -> + block.optJSONObject(j)?.let { J(it) } + } + } + } + val hw = raw.arr("hw") + val mluvnice = raw.arr("mluvnice") + val books = raw.arr("books") + + fun germanUnit(id: Int) = german.firstOrNull { it.int("id") == id } + fun netLesson(id: Int) = netLessons.firstOrNull { it.int("id") == id } + fun hwLesson(id: Int) = hw.firstOrNull { it.int("id") == id } + fun mluv(n: Int) = mluvnice.firstOrNull { it.int("id") == n } + fun book(id: String) = books.firstOrNull { it.str("id") == id } + + fun tr(key: String?, lang: UiLang): String { + if (key.isNullOrEmpty()) return "" + val hit = i18n[key] + return if (lang == UiLang.En) { + hit?.second ?: key + } else { + hit?.first ?: key + } + } + + fun fmt(key: String, lang: UiLang, vararg args: Any): String { + val template = tr(key, lang) + return try { + String.format(template, *args) + } catch (_: Exception) { + template + } + } + + companion object { + fun load(context: Context): Content { + val text = context.assets.open("content.json").bufferedReader().use { it.readText() } + return Content(JSONObject(text)) + } + } +} diff --git a/android/app/src/main/java/org/maturita/maturita/data/Json.kt b/android/app/src/main/java/org/maturita/maturita/data/Json.kt new file mode 100644 index 0000000..078b7c5 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/data/Json.kt @@ -0,0 +1,52 @@ +package org.maturita.maturita.data + +import org.json.JSONArray +import org.json.JSONObject + +class J(val o: JSONObject) { + fun has(key: String) = o.has(key) && !o.isNull(key) + fun str(key: String, fallback: String = "") = + if (!has(key)) fallback else o.optString(key, fallback) + fun strOrNull(key: String): String? = + if (!has(key)) null else o.optString(key).takeIf { it.isNotEmpty() && it != "null" } + fun bool(key: String, fallback: Boolean = false) = o.optBoolean(key, fallback) + fun int(key: String, fallback: Int = 0) = o.optInt(key, fallback) + fun obj(key: String): J? = o.optJSONObject(key)?.let { J(it) } + fun exercise(n: Int): J? = o.optJSONObject("exercises")?.optJSONObject(n.toString())?.let { J(it) } + fun arr(key: String): List { + val a = o.optJSONArray(key) ?: return emptyList() + return (0 until a.length()).mapNotNull { i -> a.optJSONObject(i)?.let { J(it) } } + } + fun strs(key: String): List { + val a = o.optJSONArray(key) ?: return emptyList() + return (0 until a.length()).mapNotNull { i -> + if (a.isNull(i)) null else a.optString(i).takeIf { it.isNotEmpty() } + } + } + fun strsOrEmpty(key: String): List { + val a = o.optJSONArray(key) ?: return emptyList() + return (0 until a.length()).map { i -> + if (a.isNull(i)) null else a.optString(i) + } + } + fun intRows(key: String): List> { + val a = o.optJSONArray(key) ?: return emptyList() + return (0 until a.length()).map { i -> + val row = a.optJSONArray(i) ?: JSONArray() + (0 until row.length()).map { j -> row.optString(j) } + } + } + + fun answerList(): List { + val a = o.optJSONArray("answers") + if (a != null) { + return (0 until a.length()).mapNotNull { i -> + if (a.isNull(i)) null else a.optString(i).takeIf { it.isNotEmpty() } + } + } + return listOfNotNull(strOrNull("answers")) + } +} + +fun JSONArray.objects(): List = + (0 until length()).mapNotNull { i -> optJSONObject(i)?.let { J(it) } } diff --git a/android/app/src/main/java/org/maturita/maturita/data/ProgressStore.kt b/android/app/src/main/java/org/maturita/maturita/data/ProgressStore.kt new file mode 100644 index 0000000..71b4e1b --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/data/ProgressStore.kt @@ -0,0 +1,75 @@ +package org.maturita.maturita.data + +import android.content.Context + +class ProgressStore(context: Context) { + private val prefs = context.getSharedPreferences("maturita", Context.MODE_PRIVATE) + + var themeId: ThemeId + get() = ThemeId.entries.getOrElse(prefs.getInt("theme", 0)) { ThemeId.Catppuccin } + set(v) { prefs.edit().putInt("theme", v.ordinal).apply() } + + var mode: ColorMode + get() = if (prefs.getString("mode", "light") == "dark") ColorMode.Dark else ColorMode.Light + set(v) { prefs.edit().putString("mode", if (v == ColorMode.Light) "light" else "dark").apply() } + + var lang: UiLang + get() = if (prefs.getString("lang", "cs") == "en") UiLang.En else UiLang.Cs + set(v) { prefs.edit().putString("lang", if (v == UiLang.En) "en" else "cs").apply() } + + fun germanDone(unit: Int, ex: Int) = prefs.getBoolean("g.$unit.$ex", false) + fun markGerman(unit: Int, ex: Int) { prefs.edit().putBoolean("g.$unit.$ex", true).apply() } + fun vocabDone(unit: Int) = prefs.getBoolean("g.$unit.vocab", false) + fun markVocab(unit: Int) { prefs.edit().putBoolean("g.$unit.vocab", true).apply() } + + fun netDone(id: Int) = prefs.getBoolean("net.$id", false) + fun markNet(id: Int) { prefs.edit().putBoolean("net.$id", true).apply() } + + fun hwDone(id: Int) = prefs.getBoolean("hw.$id", false) + fun markHw(id: Int) { prefs.edit().putBoolean("hw.$id", true).apply() } + + fun mluvDone(n: Int) = prefs.getBoolean("mluv.$n", false) + fun markMluv(n: Int) { prefs.edit().putBoolean("mluv.$n", true).apply() } + + fun bookQuiz(id: String) = prefs.getBoolean("book.$id.quiz", false) + fun markBookQuiz(id: String) { prefs.edit().putBoolean("book.$id.quiz", true).apply() } + fun bookPlot(id: String) = prefs.getBoolean("book.$id.plot", false) + fun markBookPlot(id: String) { prefs.edit().putBoolean("book.$id.plot", true).apply() } +} + +data class ProgressSum(var doneEx: Int = 0, var totalEx: Int = 0, var doneUnits: Int = 0, var openUnits: Int = 0) + +fun summarize(content: Content, p: ProgressStore): ProgressSum { + val sum = ProgressSum() + content.german.filter { it.bool("unlocked") }.forEach { u -> + val id = u.int("id") + val names = u.strs("names") + sum.totalEx += names.size + sum.openUnits += 1 + val done = names.indices.count { p.germanDone(id, it + 1) } + sum.doneEx += done + if (names.isNotEmpty() && done == names.size) sum.doneUnits += 1 + } + content.netLessons.forEach { l -> + sum.totalEx += 1 + sum.openUnits += 1 + if (p.netDone(l.int("id"))) { + sum.doneEx += 1 + sum.doneUnits += 1 + } + } + content.hw.forEach { l -> + sum.totalEx += 1 + sum.openUnits += 1 + if (p.hwDone(l.int("id"))) { + sum.doneEx += 1 + sum.doneUnits += 1 + } + } + sum.totalEx += content.mluvnice.size + sum.openUnits += 1 + val md = content.mluvnice.count { p.mluvDone(it.int("id")) } + sum.doneEx += md + if (md == content.mluvnice.size && content.mluvnice.isNotEmpty()) sum.doneUnits += 1 + return sum +} diff --git a/android/app/src/main/java/org/maturita/maturita/data/Theme.kt b/android/app/src/main/java/org/maturita/maturita/data/Theme.kt new file mode 100644 index 0000000..6791aa7 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/data/Theme.kt @@ -0,0 +1,164 @@ +package org.maturita.maturita.data + +import androidx.compose.ui.graphics.Color + +enum class ThemeId { Catppuccin, Nord, Dracula, RosePine, Ocean, Gruvbox, Solarized, Everforest, Monokai, OneDark } +enum class ColorMode { Dark, Light } +enum class UiLang { Cs, En } + +val themeNames = listOf( + "Catppuccin", "Nord", "Dracula", "Rose Pine", "Ocean", + "Gruvbox", "Solarized", "Everforest", "Monokai", "One Dark", +) + +data class Palette( + val crust: Color, + val base: Color, + val mantle: Color, + val surface0: Color, + val surface1: Color, + val surface2: Color, + val overlay: Color, + val text: Color, + val subtext: Color, + val accent: Color, + val accent2: Color, + val accent3: Color, + val success: Color, + val success2: Color, + val warning: Color, + val error: Color, + val onAccent: Color, + val node: Color, + val lockedBg: Color, + val lockedBorder: Color, + val rail: Color, + val finishDark: Color, + val finishLight: Color, + val finishStroke: Color, +) + +private fun hx(v: Long) = Color(0xFF000000L or v) + +private fun pal(vararg c: Long) = Palette( + hx(c[0]), hx(c[1]), hx(c[2]), hx(c[3]), hx(c[4]), hx(c[5]), hx(c[6]), + hx(c[7]), hx(c[8]), hx(c[9]), hx(c[10]), hx(c[11]), hx(c[12]), hx(c[13]), + hx(c[14]), hx(c[15]), hx(c[16]), hx(c[17]), hx(c[18]), hx(c[19]), hx(c[20]), + hx(c[21]), hx(c[22]), hx(c[23]), +) + +fun themePalette(id: ThemeId, mode: ColorMode): Palette { + val light = mode == ColorMode.Light + return when (id) { + ThemeId.Catppuccin -> if (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, + ) else 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, + ) + ThemeId.Nord -> if (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, + ) else 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, + ) + ThemeId.Dracula -> if (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, + ) else 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, + ) + ThemeId.RosePine -> if (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, + ) else 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, + ) + ThemeId.Ocean -> if (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, + ) else 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, + ) + ThemeId.Gruvbox -> if (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, + ) else 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, + ) + ThemeId.Solarized -> if (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, + ) else 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, + ) + ThemeId.Everforest -> if (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, + ) else 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, + ) + ThemeId.Monokai -> if (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, + ) else 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, + ) + ThemeId.OneDark -> if (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, + ) else pal( + 0x21252b, 0x282c34, 0x21252b, 0x3b4048, 0x4b5263, 0x5c6370, + 0x5c6370, 0xabb2bf, 0x7f848e, 0xc678dd, 0x61afef, 0x56b6c2, + 0x98c379, 0x56b6c2, 0xe5c07b, 0xe06c75, 0x282c34, + 0x3b4048, 0x2c313a, 0x4b5263, 0x21252b, 0x21252b, 0x3b4048, 0x4b5263, + ) + } +} diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Components.kt b/android/app/src/main/java/org/maturita/maturita/ui/Components.kt new file mode 100644 index 0000000..0973d01 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/ui/Components.kt @@ -0,0 +1,185 @@ +package org.maturita.maturita.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.maturita.maturita.AppViewModel +import org.maturita.maturita.data.Palette + +private val Pill = RoundedCornerShape(999.dp) +private val Card = RoundedCornerShape(20.dp) + +@Composable +fun PrimaryButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) { + Box( + modifier + .clip(Pill) + .background(palette.accent) + .clickable(onClick = onClick) + .padding(horizontal = 22.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, color = palette.onAccent, fontWeight = FontWeight.SemiBold, fontSize = 15.sp) + } +} + +@Composable +fun PillButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) { + Box( + modifier + .clip(Pill) + .background(palette.text.copy(alpha = 0.06f)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text(label, color = palette.text, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + } +} + +@Composable +fun IconHit(onClick: () -> Unit, content: @Composable () -> Unit) { + Box( + Modifier + .size(40.dp) + .clip(CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { content() } +} + +@Composable +fun PageTop(vm: AppViewModel, back: () -> Unit, title: String, subtitle: String? = null) { + val p = vm.palette + Box(Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 14.dp)) { + Box(Modifier.align(Alignment.CenterStart)) { + IconHit(back) { BackIcon(p.text) } + } + Column(Modifier.align(Alignment.Center).padding(horizontal = 48.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(title, color = p.text, fontSize = 24.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + if (!subtitle.isNullOrBlank()) { + Text(subtitle, color = p.subtext, fontSize = 14.sp, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 4.dp)) + } + } + } +} + +@Composable +fun CardBox(palette: Palette, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box( + modifier + .clip(Card) + .background(palette.mantle) + .border(1.dp, palette.text.copy(alpha = 0.07f), Card) + .padding(20.dp), + ) { content() } +} + +@Composable +fun FeedbackLine(text: String, kind: String, palette: Palette) { + if (text.isEmpty()) return + val color = when (kind) { + "ok" -> palette.success + "warn" -> palette.warning + else -> palette.error + } + Text(text, color = color, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +fun Meaning(text: String, palette: Palette, visible: Boolean) { + if (!visible || text.isEmpty()) return + Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp)) +} + +@Composable +fun WordField( + value: String, + onValue: (String) -> Unit, + palette: Palette, + placeholder: String, + mark: Boolean? = null, + modifier: Modifier = Modifier, +) { + val border = when (mark) { + true -> palette.success + false -> palette.error + null -> palette.text.copy(alpha = 0.12f) + } + Box( + modifier + .clip(RoundedCornerShape(14.dp)) + .background(palette.surface0) + .border(1.dp, border, RoundedCornerShape(14.dp)) + .padding(horizontal = 14.dp, vertical = 12.dp) + .fillMaxWidth(), + ) { + if (value.isEmpty()) Text(placeholder, color = palette.overlay, fontSize = 15.sp) + BasicTextField( + value = value, + onValueChange = onValue, + textStyle = TextStyle(color = palette.text, fontSize = 15.sp), + cursorBrush = SolidColor(palette.accent), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +fun Segmented(palette: Palette, content: @Composable RowScope.() -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clip(Pill) + .background(palette.surface1) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + content = content, + ) +} + +@Composable +fun SegChip(label: String, selected: Boolean, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) { + Box( + modifier + .clip(Pill) + .background(if (selected) palette.mantle else Color.Transparent) + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, color = palette.text, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + } +} + +@Composable +fun Hint(text: String, palette: Palette) { + Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp)) +} + +@Composable +fun Prompt(text: String, palette: Palette) { + Text(text, color = palette.text, fontSize = 16.sp, fontWeight = FontWeight.Medium) +} diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt b/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt new file mode 100644 index 0000000..912a2a3 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt @@ -0,0 +1,1029 @@ +package org.maturita.maturita.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.maturita.maturita.AppViewModel +import org.maturita.maturita.data.J +import org.maturita.maturita.data.Palette +import org.maturita.maturita.data.answerAccepts +import org.maturita.maturita.data.answerIncomplete +import org.maturita.maturita.data.hasUmlaut +import org.maturita.maturita.data.netTxtEq +import org.maturita.maturita.data.normalizeAnswer + +@Composable +fun ExerciseScaffold( + vm: AppViewModel, + title: String, + sub: String?, + action: String, + onAction: () -> Unit, + feedback: String, + kind: String, + content: @Composable () -> Unit, +) { + val p = vm.palette + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, title, sub) + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { content() } + FeedbackLine(feedback, kind, p) + Box(Modifier.padding(vertical = 12.dp)) { + PrimaryButton(action, p, onClick = onAction) + } + } +} + +@Composable +fun GermanExercise(vm: AppViewModel, unitId: Int, ex: Int) { + val spec = vm.content.germanUnit(unitId)?.exercise(ex) ?: return + DispatchExercise(vm, spec, vm.tr(spec.str("title").ifEmpty { spec.str("title") }).ifEmpty { spec.str("title") }, onDone = { vm.markGerman(unitId, ex) }) +} + +@Composable +fun VocabExercise(vm: AppViewModel, unitId: Int) { + val spec = vm.content.germanUnit(unitId)?.obj("vocab") ?: return + DispatchExercise(vm, spec, vm.tr("Vokabeltraining"), onDone = { vm.markVocab(unitId) }) +} + +@Composable +fun DispatchExercise(vm: AppViewModel, spec: J, titleFallback: String, onDone: () -> Unit) { + val title = spec.str("title").ifEmpty { titleFallback } + val sub = spec.strOrNull("sub")?.let { vm.tr(it) } + when (spec.str("type")) { + "choice" -> ChoiceEx(vm, title, sub, spec, onDone) + "assembly" -> AssemblyEx(vm, title, sub, spec, onDone) + "typed", "kw" -> TypedEx(vm, title, sub, spec, onDone) + "free", "profile" -> FreeEx(vm, title, sub, spec, onDone) + "assign" -> AssignEx(vm, title, sub, spec, onDone) + "hangman" -> HangmanEx(vm, title, sub, spec, onDone) + "vocab" -> VocabEx(vm, title, sub, spec, onDone) + "dialog" -> DialogEx(vm, title, sub, spec, onDone) + "number", "count", "seq", "verb", "verbclue", "verb_sections", "ordne", "fill" -> ComboEx(vm, title, sub, spec, onDone) + "ex2" -> Ex2Ex(vm, title, sub, spec, onDone) + "letters", "letters_gap" -> LettersEx(vm, title, sub, spec, onDone) + "table" -> TableEx(vm, title, sub, spec, onDone) + "reveal" -> RevealEx(vm, title, sub, spec, onDone) + else -> Text("?", color = vm.palette.text) + } +} + +@Composable +private fun ChoiceEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val qs = spec.arr("questions") + val meanings = spec.strs("meanings") + val expls = spec.strs("expls") + val picks = remember { mutableStateListOf(*Array(qs.size) { -1 }) } + var fb by remember { mutableStateOf("" to "") } + var revealed by remember { mutableStateOf(false) } + val p = vm.palette + ExerciseScaffold(vm, title, sub, vm.tr("check"), { + var ok = 0 + qs.forEachIndexed { i, q -> if (picks[i] == q.int("correct")) ok++ } + if (ok == qs.size) { fb = vm.tr("feedback_ok") to "ok"; onDone() } + else fb = vm.tr("feedback_retry_short") to "err" + revealed = true + }, fb.first, fb.second) { + qs.forEachIndexed { i, q -> + Text("${i + 1}. ${q.str("prompt")}", color = p.text, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 6.dp)) + q.strs("options").forEachIndexed { o, opt -> + val sel = picks[i] == o + val mark = if (revealed && sel) picks[i] == q.int("correct") else null + OptionChip(opt, sel, mark, p) { picks[i] = o } + } + if (revealed) { + meanings.getOrNull(i)?.let { Meaning(vm.tr(it), p, true) } + expls.getOrNull(i)?.let { Meaning(it, p, true) } + } + Spacer(Modifier.height(10.dp)) + } + } +} + +@Composable +private fun OptionChip(text: String, selected: Boolean, mark: Boolean?, p: Palette, onClick: () -> Unit) { + val border = when (mark) { + true -> p.success + false -> p.error + null -> if (selected) p.accent else p.text.copy(0.1f) + } + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 6.dp) + .clip(RoundedCornerShape(14.dp)) + .background(if (selected) p.surface1 else p.mantle) + .border(1.dp, border, RoundedCornerShape(14.dp)) + .clickable(onClick = onClick) + .padding(12.dp), + ) { Text(text, color = p.text) } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AssemblyEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val items = spec.arr("items") + val meanings = spec.strs("meanings") + val placed = remember { + items.map { item -> mutableStateListOf() } + } + val pools = remember { + items.map { item -> + val n = item.strs("words").size + mutableStateListOf(*((0 until n).shuffled().toTypedArray())) + } + } + var fb by remember { mutableStateOf("" to "") } + var revealed by remember { mutableStateOf(false) } + val p = vm.palette + ExerciseScaffold(vm, title, sub, vm.tr("check"), { + val all = items.indices.all { i -> + val words = items[i].strs("words") + placed[i].size == words.size && placed[i].mapIndexed { idx, w -> words[w] == words[idx] }.all { it } || + placed[i].map { words[it] } == words + } + // compare placed order to original word order + val ok = items.indices.all { i -> + val words = items[i].strs("words") + placed[i].size == words.size && placed[i].map { words[it] } == words + } + if (ok) { fb = vm.tr("feedback_ok") to "ok"; onDone() } + else fb = vm.tr("feedback_sentences") to "err" + revealed = true + }, fb.first, fb.second) { + items.forEachIndexed { i, item -> + item.strOrNull("prompt")?.let { Hint(it, p) } + FlowRow(Modifier.fillMaxWidth().padding(bottom = 6.dp).clip(RoundedCornerShape(14.dp)).background(p.surface0).padding(8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (placed[i].isEmpty()) Text("…", color = p.overlay) + placed[i].forEach { idx -> + Chip(item.strs("words")[idx], p) { + placed[i].remove(idx) + pools[i].add(idx) + } + } + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + pools[i].toList().forEach { idx -> + Chip(item.strs("words")[idx], p) { + pools[i].remove(idx) + placed[i].add(idx) + } + } + } + if (revealed) meanings.getOrNull(i)?.let { Meaning(vm.tr(it), p, true) } + Spacer(Modifier.height(12.dp)) + } + } +} + +@Composable +private fun Chip(text: String, p: Palette, onClick: () -> Unit) { + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(p.mantle) + .border(1.dp, p.text.copy(0.1f), RoundedCornerShape(999.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { Text(text, color = p.text, fontWeight = FontWeight.Medium) } +} + +@Composable +private fun TypedEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val rows = spec.arr("rows") + val items = if (rows.isNotEmpty()) rows else spec.arr("items") + val fields = remember { + items.map { q -> + val n = maxOf(1, q.strs("hints").size, q.answerList().size) + mutableStateListOf(*Array(n) { "" }) + } + } + var fb by remember { mutableStateOf("" to "") } + var revealed by remember { mutableStateOf(false) } + val p = vm.palette + val umlaut = items.any { q -> + q.answerList().any { hasUmlaut(it) } || hasUmlaut(q.str("prompt")) + } + ExerciseScaffold(vm, title, sub, vm.tr("check"), { + var ok = 0 + var total = 0 + var incomplete = false + items.forEachIndexed { i, q -> + val answers = q.answerList() + fields[i].forEachIndexed { j, value -> + total++ + val ans = answers.getOrNull(j) ?: "" + val norm = normalizeAnswer(value) + when { + answerAccepts(norm, ans) -> ok++ + answerIncomplete(norm, ans) -> incomplete = true + } + } + } + when { + total > 0 && ok == total -> { fb = vm.tr("feedback_ok") to "ok"; onDone() } + incomplete -> fb = vm.tr("trans_incomplete") to "warn" + else -> fb = vm.tr("feedback_retry") to "err" + } + revealed = true + }, fb.first, fb.second) { + spec.strOrNull("note")?.let { Hint(it, p) } + spec.strOrNull("sample")?.let { Hint(it, p) } + spec.strOrNull("bank")?.let { Hint("${vm.tr("wordbank")} $it", p) } + if (umlaut) Hint(vm.tr("hint_umlauts"), p) + items.forEachIndexed { i, q -> + Prompt(q.str("prompt"), p) + val answers = q.answerList() + val hints = q.strs("hints") + fields[i].forEachIndexed { j, value -> + val ans = answers.getOrNull(j) ?: "" + WordField( + value, + { fields[i][j] = it }, + p, + hints.getOrNull(j) ?: vm.tr("your_answer"), + if (revealed) answerAccepts(normalizeAnswer(value), ans) else null, + ) + } + if (revealed) { + q.strOrNull("meaning")?.let { Meaning(vm.tr(it), p, true) } + q.strOrNull("mean")?.let { Meaning(vm.tr(it), p, true) } + q.strOrNull("german")?.let { Meaning(it, p, true) } + q.strOrNull("shown")?.let { Meaning(it, p, true) } + } + Spacer(Modifier.height(10.dp)) + } + } +} + +@Composable +private fun FreeEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val qs = if (spec.arr("questions").isNotEmpty()) spec.arr("questions") else spec.arr("rows") + val shown = remember { mutableStateListOf(*Array(qs.size) { false }) } + var fb by remember { mutableStateOf("" to "") } + val p = vm.palette + val values = remember { mutableStateListOf(*Array(qs.size) { "" }) } + ExerciseScaffold(vm, title, sub, vm.tr("finish"), { + fb = vm.tr("feedback_ok") to "ok" + qs.indices.forEach { shown[it] = true } + onDone() + }, fb.first, fb.second) { + spec.strOrNull("tip")?.let { Hint(vm.tr(it), p) } + qs.forEachIndexed { i, q -> + Prompt(q.str("question").ifEmpty { q.str("stem") }, p) + WordField(values[i], { values[i] = it }, p, vm.tr("your_answer")) + PillButton(vm.tr("show_sample"), p, Modifier.padding(vertical = 6.dp)) { shown[i] = !shown[i] } + if (shown[i]) { + val sample = q.str("sample") + val qcs = q.str("qCs") + val acs = q.str("aCs") + Meaning( + if (qcs.isNotEmpty()) vm.fmt("sample_fmt", vm.tr(qcs), sample, vm.tr(acs)) else sample, + p, true, + ) + } + Spacer(Modifier.height(10.dp)) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AssignEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val items = spec.arr("items") + val groups = spec.strs("groups") + val meanings = spec.strs("meanings") + var active by remember { mutableIntStateOf(0) } + val loc = remember { mutableStateListOf(*Array(items.size) { -1 }) } + var fb by remember { mutableStateOf("" to "") } + var revealed by remember { mutableStateOf(false) } + val p = vm.palette + ExerciseScaffold(vm, title, sub, vm.tr("check"), { + val ok = items.indices.all { loc[it] == items[it].int("group") } + if (ok) { fb = vm.tr("feedback_ok") to "ok"; onDone() } + else fb = vm.tr("feedback_retry") to "err" + revealed = true + }, fb.first, fb.second) { + Hint(vm.tr("assign_hint"), p) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + groups.forEachIndexed { i, g -> + SegChip(g, active == i, p) { active = i } + } + } + Spacer(Modifier.height(10.dp)) + Text(vm.tr("wordbank"), color = p.subtext, fontSize = 12.sp) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + items.forEachIndexed { i, it -> + if (loc[i] == -1) { + val label = listOfNotNull(it.strOrNull("emoji"), it.str("label")).joinToString(" ") + Chip(label, p) { loc[i] = active } + } + } + } + groups.forEachIndexed { g, name -> + Spacer(Modifier.height(10.dp)) + Text(name, color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + items.forEachIndexed { i, it -> + if (loc[i] == g) { + val label = listOfNotNull(it.strOrNull("emoji"), it.str("label")).joinToString(" ") + Chip(label, p) { loc[i] = -1 } + } + } + } + } + if (revealed) items.forEachIndexed { i, _ -> + meanings.getOrNull(i)?.let { Meaning(vm.tr(it), p, true) } + } + } +} + +@Composable +private fun HangmanEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val words = spec.strs("words") + val tips = spec.strs("tips") + val letters = spec.strs("letters") + var word by remember { mutableIntStateOf(0) } + var misses by remember { mutableIntStateOf(0) } + val guessed = remember { mutableStateListOf(*Array(letters.size) { false }) } + var fb by remember { mutableStateOf("" to "") } + var finished by remember { mutableStateOf(false) } + val p = vm.palette + val cur = words.getOrNull(word)?.uppercase() ?: "" + fun solved() = cur.all { ch -> + val idx = letters.indexOfFirst { it.equals(ch.toString(), true) } + idx >= 0 && guessed[idx] + } + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, title, sub) + Text(vm.fmt("hm_progress", word + 1, words.size), color = p.subtext) + tips.getOrNull(word)?.let { Hint("${vm.tr("hm_hint")}: ${vm.tr(it)}", p) } + Canvas(Modifier.fillMaxWidth().height(160.dp)) { + val x = size.width * 0.3f + drawLine(p.text, Offset(40f, size.height - 10), Offset(size.width * 0.55f, size.height - 10), 6f, StrokeCap.Round) + drawLine(p.text, Offset(70f, size.height - 10), Offset(70f, 16f), 6f, StrokeCap.Round) + drawLine(p.text, Offset(70f, 16f), Offset(x + 40, 16f), 6f, StrokeCap.Round) + if (misses > 0) drawLine(p.text, Offset(x + 40, 16f), Offset(x + 40, 36f), 4f) + if (misses > 1) drawCircle(p.text, 16f, Offset(x + 40, 52f), style = androidx.compose.ui.graphics.drawscope.Stroke(4f)) + if (misses > 2) drawLine(p.text, Offset(x + 40, 68f), Offset(x + 40, 110f), 4f) + if (misses > 3) drawLine(p.text, Offset(x + 40, 80f), Offset(x + 18, 100f), 4f) + if (misses > 4) drawLine(p.text, Offset(x + 40, 80f), Offset(x + 62, 100f), 4f) + if (misses > 5) drawLine(p.text, Offset(x + 40, 110f), Offset(x + 18, 140f), 4f) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + cur.forEach { ch -> + val idx = letters.indexOfFirst { it.equals(ch.toString(), true) } + val show = idx >= 0 && guessed[idx] + Text(if (show) ch.toString() else "_", color = p.text, fontSize = 26.sp, fontWeight = FontWeight.Bold) + } + } + Spacer(Modifier.height(8.dp)) + letters.chunked(10).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + row.forEach { letter -> + val i = letters.indexOf(letter) + Box( + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (guessed[i]) p.surface1 else p.mantle) + .clickable(enabled = !guessed[i] && !finished) { + guessed[i] = true + if (!cur.contains(letter, true)) { + misses++ + if (misses >= 6) { + fb = vm.fmt("hm_fail", cur) to "err" + } + } else if (solved()) { + if (word + 1 >= words.size) { + finished = true + fb = vm.tr("hm_done") to "ok" + onDone() + } else { + fb = vm.tr("hm_wrong") to "ok" + word++ + misses = 0 + guessed.indices.forEach { guessed[it] = false } + } + } + } + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { Text(letter, color = p.text, fontWeight = FontWeight.Bold) } + } + } + Spacer(Modifier.height(4.dp)) + } + FeedbackLine(fb.first, fb.second, p) + if (misses >= 6 && !finished) { + PillButton(vm.tr("hm_retry"), p) { + misses = 0 + guessed.indices.forEach { guessed[it] = false } + fb = "" to "" + } + } + } +} + +@Composable +private fun VocabEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val cards = remember { + spec.arr("sections").flatMap { sec -> + sec.arr("rows").map { Triple(sec.str("header"), it.str("prompt"), it.str("answers")) } + }.toMutableList() + } + var index by remember { mutableIntStateOf(0) } + var done by remember { mutableIntStateOf(0) } + var value by remember { mutableStateOf("") } + var fb by remember { mutableStateOf("" to "") } + val total = remember { cards.size } + val p = vm.palette + val cur = cards.getOrNull(index) + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, title, sub) + Text(vm.fmt("trans_progress", done, total), color = p.subtext) + if (cur == null) { + Text(vm.tr("trans_done"), color = p.success, fontWeight = FontWeight.Bold) + } else { + Hint(cur.first, p) + Prompt(cur.second, p) + if (hasUmlaut(cur.third)) Hint(vm.tr("hint_umlauts"), p) + WordField(value, { value = it }, p, vm.tr("your_answer")) + Spacer(Modifier.height(10.dp)) + PrimaryButton(vm.tr("check"), p) { + val norm = normalizeAnswer(value) + when { + answerAccepts(norm, cur.third) -> { + done++ + cards.removeAt(index) + if (cards.isEmpty()) { fb = vm.tr("trans_done") to "ok"; onDone() } + else { if (index >= cards.size) index = 0; value = ""; fb = "" to "" } + } + answerIncomplete(norm, cur.third) -> fb = vm.tr("trans_incomplete") to "warn" + else -> { + fb = vm.fmt("trans_wrong", cur.third.split('|').first()) to "err" + val item = cards.removeAt(index) + cards.add(item) + if (index >= cards.size) index = 0 + value = "" + } + } + } + FeedbackLine(fb.first, fb.second, p) + } + } +} + +@Composable +private fun DialogEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val pool = spec.strs("pool") + val meanings = spec.strs("meanings") + val rows = spec.arr("dialogues").flatMap { it.arr("rows") } + ComboRows(vm, title, sub, pool, rows.size, onDone) { picks, expected, revealed -> + var i = 0 + spec.arr("dialogues").forEach { d -> + Text(d.str("name"), color = vm.palette.subtext, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(vertical = 6.dp)) + d.arr("rows").forEach { row -> + val idx = i++ + ComboLine(vm, pool, picks, expected, idx, row.str("answer"), + prefix = "${row.str("speaker")} ${row.str("before")}", + suffix = row.str("after"), + meaning = meanings.getOrNull(idx), + revealed = revealed, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ComboEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val pool = spec.strs("pool") + val rows = spec.arr("rows") + val meanings = spec.strs("meanings") + val type = spec.str("type") + if (type == "verb_sections") { + val sections = spec.arr("sections") + val allRows = sections.flatMap { it.arr("rows") } + ComboRows(vm, title, sub, spec.strs("pool"), allRows.size, onDone, spec.bool("transUpfront")) { picks, expected, revealed -> + var i = 0 + sections.forEach { sec -> + Text(sec.str("title"), color = vm.palette.subtext, fontWeight = FontWeight.SemiBold) + sec.arr("rows").forEachIndexed { ri, row -> + val idx = i++ + ComboLine( + vm, spec.strs("pool"), picks, expected, idx, row.str("answer"), + prefix = row.str("before"), suffix = row.str("after"), + meaning = sec.strs("meanings").getOrNull(ri), + revealed = revealed || spec.bool("transUpfront"), + ) + } + } + } + return + } + if (type == "ordne") { + ComboRows(vm, title, sub, emptyList(), rows.size * 2, onDone) { picks, expected, revealed -> + rows.forEachIndexed { i, r -> + ComboLine(vm, spec.strs("fwPool"), picks, expected, i * 2, r.str("fw"), prefix = "${i + 1}. ", suffix = r.str("mid"), revealed = revealed) + ComboLine(vm, spec.strs("ansPool"), picks, expected, i * 2 + 1, r.str("ans"), prefix = "→ ", meaning = meanings.getOrNull(i), revealed = revealed) + } + } + return + } + if (type == "fill") { + val blanks = rows.sumOf { it.answerList().size } + ComboRows(vm, title, sub, pool, blanks, onDone, spec.bool("preMeaning")) { picks, expected, revealed -> + spec.strOrNull("sample")?.let { Hint(it, vm.palette) } + var i = 0 + rows.forEachIndexed { ri, r -> + val segs = r.strsOrEmpty("segs") + val ans = r.answerList() + FlowRow( + Modifier.fillMaxWidth().padding(bottom = 6.dp), + verticalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + r.strOrNull("num")?.let { Text(it, color = vm.palette.text, fontWeight = FontWeight.SemiBold) } + segs.forEachIndexed { si, seg -> + if (!seg.isNullOrEmpty()) Text(seg, color = vm.palette.text) + if (si < ans.size) { + val idx = i++ + ComboLine(vm, pool, picks, expected, idx, ans[si], revealed = revealed, inline = true) + } + } + meanings.getOrNull(ri)?.let { Text(vm.tr(it), color = vm.palette.subtext, fontSize = 12.sp) } + } + if (revealed || spec.bool("preMeaning") || spec.bool("showGerman")) { + r.strOrNull("mean")?.let { Meaning(vm.tr(it), vm.palette, true) } + } + } + } + return + } + ComboRows(vm, title, sub, pool, rows.size, onDone, spec.bool("preMeaning")) { picks, expected, revealed -> + spec.strOrNull("sample")?.let { Hint(it, vm.palette) } + rows.forEachIndexed { i, r -> + val prefix = when (type) { + "number" -> "${r.str("digits")} = " + "count" -> "${List(r.int("count")) { r.str("emoji") }.joinToString(" ")} ${r.str("noun")} " + "seq" -> r.str("before") + "verbclue" -> "${r.str("emoji")} ${r.str("before")}" + else -> r.str("before") + } + val suffix = when (type) { + "seq" -> r.str("after") + "verbclue", "verb" -> r.str("after") + else -> "" + } + ComboLine(vm, pool, picks, expected, i, r.str("answer"), prefix, suffix, meanings.getOrNull(i) ?: r.strOrNull("mean"), revealed || spec.bool("preMeaning")) + } + } +} + +@Composable +private fun ComboRows( + vm: AppViewModel, + title: String, + sub: String?, + @Suppress("UNUSED_PARAMETER") pool: List, + n: Int, + onDone: () -> Unit, + revealMean: Boolean = false, + body: @Composable (MutableList, MutableList, Boolean) -> Unit, +) { + val picks = remember { mutableStateListOf(*Array(n) { "" }) } + val expected = remember { mutableStateListOf(*Array(n) { "" }) } + var fb by remember { mutableStateOf("" to "") } + var revealed by remember { mutableStateOf(revealMean) } + ExerciseScaffold(vm, title, sub, vm.tr("check"), { + val ok = expected.indices.all { i -> + val exp = expected.getOrNull(i).orEmpty() + exp.isEmpty() || answerAccepts(normalizeAnswer(picks[i]), exp) + } + if (ok) { fb = vm.tr("feedback_ok") to "ok"; onDone() } + else fb = vm.tr("feedback_retry") to "err" + revealed = true + }, fb.first, fb.second) { + body(picks, expected, revealed) + } +} + +@Composable +private fun ComboLine( + vm: AppViewModel, + pool: List, + picks: MutableList, + expected: MutableList, + idx: Int, + answer: String, + prefix: String = "", + suffix: String = "", + meaning: String? = null, + revealed: Boolean, + inline: Boolean = false, +) { + if (idx in expected.indices) expected[idx] = answer + val p = vm.palette + var open by remember { mutableStateOf(false) } + val box = @Composable { + Box { + Box( + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(p.mantle) + .border(1.dp, if (revealed && picks.getOrNull(idx).orEmpty().isNotEmpty()) { + if (answerAccepts(normalizeAnswer(picks[idx]), answer)) p.success else p.error + } else p.text.copy(0.1f), RoundedCornerShape(12.dp)) + .clickable { open = true } + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + val pick = picks.getOrNull(idx).orEmpty() + Text(pick.ifEmpty { "—" }, color = if (pick.isEmpty()) p.overlay else p.text) + } + DropdownMenu(open, { open = false }) { + pool.forEach { w -> + DropdownMenuItem({ Text(w) }, { + if (idx in picks.indices) picks[idx] = w + open = false + }) + } + } + } + } + if (inline) { + box() + return + } + Row(Modifier.fillMaxWidth().padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { + if (prefix.isNotBlank()) Text(prefix, color = p.text, modifier = Modifier.padding(end = 6.dp)) + box() + if (suffix.isNotBlank()) Text(suffix, color = p.text, modifier = Modifier.padding(start = 6.dp)) + } + if (revealed && !meaning.isNullOrBlank()) Meaning(vm.tr(meaning), p, true) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Ex2Ex(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val items = spec.arr("items") + val pool = spec.strs("pool") + val blanks = items.sumOf { it.arr("rows").sumOf { r -> r.strs("answers").size } } + ComboRows(vm, title, sub, pool, blanks, onDone) { picks, expected, revealed -> + var i = 0 + items.forEach { item -> + Meaning(item.str("czech"), vm.palette, true) + item.arr("rows").forEach { row -> + val segs = row.strsOrEmpty("segs") + val ans = row.strs("answers") + FlowRow(verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + row.strOrNull("num")?.let { Text(it, color = vm.palette.text, modifier = Modifier.padding(end = 6.dp)) } + segs.forEachIndexed { si, seg -> + if (!seg.isNullOrEmpty()) Text(seg, color = vm.palette.text) + if (si < ans.size) { + val idx = i++ + ComboLine(vm, pool, picks, expected, idx, ans[si], revealed = revealed, inline = true) + } + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LettersEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val rows = spec.arr("rows") + val fields = remember { + mutableStateListOf(*rows.flatMap { it.strs("answers") }.map { "" }.toTypedArray()) + } + var fb by remember { mutableStateOf("" to "") } + var revealed by remember { mutableStateOf(false) } + val p = vm.palette + ExerciseScaffold(vm, title, sub, vm.tr("check"), { + var i = 0 + var ok = 0 + var total = 0 + rows.forEach { r -> + r.strs("answers").forEach { ans -> + total++ + if (answerAccepts(normalizeAnswer(fields[i]), ans)) ok++ + i++ + } + } + if (ok == total) { fb = vm.tr("feedback_ok") to "ok"; onDone() } + else fb = vm.tr("feedback_retry") to "err" + revealed = true + }, fb.first, fb.second) { + if (rows.any { it.strs("answers").any { a -> hasUmlaut(a) } }) Hint(vm.tr("hint_umlauts"), p) + var i = 0 + rows.forEach { r -> + r.strOrNull("num")?.let { Text(it, color = p.subtext) } + FlowRow(verticalArrangement = Arrangement.Center) { + val segs = r.strsOrEmpty("segs") + val ans = r.strs("answers") + segs.forEachIndexed { si, seg -> + if (!seg.isNullOrEmpty()) Text(seg, color = p.text) + if (si < ans.size) { + val idx = i++ + Box(Modifier.width(72.dp).padding(horizontal = 2.dp)) { + WordField(fields[idx], { fields[idx] = it }, p, "", + if (revealed) answerAccepts(normalizeAnswer(fields[idx]), ans[si]) else null) + } + } + } + } + if (revealed) r.strOrNull("czech")?.let { Meaning(it, p, true) } + if (revealed) r.strOrNull("mean")?.let { Meaning(it, p, true) } + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun TableEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val persons = spec.strs("persons") + val nouns = spec.strs("nouns") + val answers = spec.intRows("answers") + val fields = remember { mutableStateListOf(*Array(nouns.size * persons.size) { "" }) } + var fb by remember { mutableStateOf("" to "") } + var revealed by remember { mutableStateOf(false) } + val p = vm.palette + ExerciseScaffold(vm, title, sub, vm.tr("check"), { + var ok = 0 + answers.forEachIndexed { r, row -> + row.forEachIndexed { c, ans -> + val i = r * persons.size + c + if (normalizeAnswer(fields[i]) == normalizeAnswer(ans)) ok++ + } + } + if (ok == fields.size) { fb = vm.tr("feedback_ok") to "ok"; onDone() } + else fb = vm.tr("feedback_retry") to "err" + revealed = true + }, fb.first, fb.second) { + nouns.forEachIndexed { r, noun -> + Text(noun, color = p.text, fontWeight = FontWeight.Bold) + persons.forEachIndexed { c, person -> + val i = r * persons.size + c + Text(person, color = p.subtext, fontSize = 12.sp) + WordField(fields[i], { fields[i] = it }, p, "") + } + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun RevealEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { + val items = spec.arr("items") + val shown = remember { mutableStateListOf(*Array(items.size) { false }) } + var fb by remember { mutableStateOf("" to "") } + val p = vm.palette + ExerciseScaffold(vm, title, sub, vm.tr("finish"), { + items.indices.forEach { shown[it] = true } + fb = vm.tr("feedback_ok") to "ok" + onDone() + }, fb.first, fb.second) { + spec.strOrNull("note")?.let { Hint(it, p) } + items.forEachIndexed { i, it -> + Prompt(it.str("prompt"), p) + PillButton(vm.tr("show_sample"), p, Modifier.padding(vertical = 6.dp)) { shown[i] = !shown[i] } + if (shown[i]) Meaning(it.str("solution"), p, true) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +fun MluvExercise(vm: AppViewModel, n: Int) { + val spec = vm.content.mluv(n) ?: return + DispatchExercise(vm, spec, spec.str("title")) { vm.markMluv(n) } +} + +@Composable +fun NetQuizScreen(vm: AppViewModel, id: Int) { + val lesson = vm.content.netLesson(id) ?: return + if (id == 1) { + VlsmEx(vm, lesson) + return + } + val spec = J(org.json.JSONObject().apply { + put("type", "choice") + put("title", vm.tr(lesson.str("exTitleKey"))) + put("sub", "net_quiz_intro") + put("questions", lesson.o.optJSONArray("quiz")) + }) + DispatchExercise(vm, spec, vm.tr(lesson.str("exTitleKey"))) { vm.markNet(id) } +} + +@Composable +fun HwQuizScreen(vm: AppViewModel, id: Int) { + val lesson = vm.content.hwLesson(id) ?: return + val spec = J(org.json.JSONObject().apply { + put("type", "choice") + put("title", vm.tr(lesson.str("exTitleKey"))) + put("sub", lesson.str("quizHeadKey")) + put("questions", lesson.o.optJSONArray("quiz")) + }) + DispatchExercise(vm, spec, vm.tr(lesson.str("exTitleKey"))) { vm.markHw(id) } +} + +private class VlsmRow { + var pfx by mutableStateOf("") + var net by mutableStateOf("") + var bc by mutableStateOf("") + var lo by mutableStateOf("") + var hi by mutableStateOf("") +} + +@Composable +private fun VlsmEx(vm: AppViewModel, @Suppress("UNUSED_PARAMETER") lesson: J) { + val tasks = vm.content.netTasks + val answers = vm.content.netAnswers + val done = remember { mutableStateListOf(*Array(tasks.size) { false }) } + val rows = remember { + answers.map { block -> List(block.size) { VlsmRow() } } + } + var fb by remember { mutableStateOf("" to "") } + val p = vm.palette + fun maybeFinish() { + if (done.isNotEmpty() && done.all { it }) vm.markNet(1) + } + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, vm.tr("net_ex_title"), null) + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + tasks.forEachIndexed { ti, task -> + val block = answers.getOrNull(ti).orEmpty() + val infeasible = block.isEmpty() || block.all { it.int("pfx") <= 0 } + Text(task.str("prompt"), color = p.text, modifier = Modifier.padding(vertical = 8.dp)) + if (infeasible) { + Hint(vm.tr("net_infeasible"), p) + } else { + block.forEachIndexed { si, a -> + val field = rows.getOrNull(ti)?.getOrNull(si) ?: return@forEachIndexed + Text("Podsíť ${'A' + si}", color = p.subtext, fontWeight = FontWeight.SemiBold) + PrefixMenu(field.pfx, p) { field.pfx = it } + WordField(field.net, { field.net = it }, p, "síť") + WordField(field.bc, { field.bc = it }, p, "broadcast") + WordField(field.lo, { field.lo = it }, p, "první uzel") + WordField(field.hi, { field.hi = it }, p, "poslední uzel") + } + PillButton(vm.tr("check"), p, Modifier.padding(vertical = 6.dp)) { + val ok = block.indices.all { si -> + val a = block[si] + val field = rows[ti][si] + field.pfx.removePrefix("/").toIntOrNull() == 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") to "ok" + maybeFinish() + } else { + fb = vm.tr("feedback_retry") to "err" + } + } + } + PillButton(vm.tr("net_solution"), p) { + if (!infeasible) { + block.forEachIndexed { si, a -> + val field = rows[ti][si] + field.pfx = "/${a.int("pfx")}" + field.net = a.str("net") + field.bc = a.str("bcast") + field.lo = a.str("lo") + field.hi = a.str("hi") + } + } + done[ti] = true + fb = "" to "" + maybeFinish() + } + if (done[ti]) Meaning(task.str("solution"), p, true) + Spacer(Modifier.height(12.dp)) + } + } + FeedbackLine(fb.first, fb.second, p) + } +} + +@Composable +private fun PrefixMenu(value: String, p: Palette, onPick: (String) -> Unit) { + var open by remember { mutableStateOf(false) } + Box(Modifier.padding(vertical = 4.dp)) { + Box( + Modifier.clip(RoundedCornerShape(12.dp)).background(p.mantle).clickable { open = true }.padding(10.dp), + ) { Text(value.ifEmpty { "/…" }, color = p.text) } + DropdownMenu(open, { open = false }) { + (8..30).forEach { n -> + DropdownMenuItem({ Text("/$n") }, { onPick("/$n"); open = false }) + } + } + } +} + +@Composable +fun LitQuizScreen(vm: AppViewModel, id: String) { + val book = vm.content.book(id) ?: return + val qs = book.arr("quiz") + var idx by remember { mutableIntStateOf(0) } + var score by remember { mutableIntStateOf(0) } + var answered by remember { mutableStateOf(false) } + var finished by remember { mutableStateOf(false) } + val p = vm.palette + val kahoot = listOf(p.accent, p.error, p.warning, p.success) + Column(Modifier.fillMaxSize().padding(16.dp)) { + PageTop(vm, { vm.back() }, vm.tr(book.str("quizTitle")), vm.tr(book.str("quizSub"))) + if (finished) { + Text(vm.tr("lit_finished"), color = p.text, fontWeight = FontWeight.Bold, fontSize = 22.sp) + Text(vm.tr("lit_finished_text"), color = p.subtext) + Text(vm.fmt("lit_score_fmt", score, qs.size), color = p.text, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(12.dp)) + PrimaryButton(vm.tr("lit_again"), p) { idx = 0; score = 0; answered = false; finished = false } + } else { + val q = qs[idx] + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(vm.fmt("lit_question_fmt", idx + 1, qs.size), color = p.subtext) + Text(vm.fmt("lit_score_fmt", score, qs.size), color = p.subtext) + } + Text(q.str("prompt"), color = p.text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(vertical = 12.dp)) + q.strs("options").forEachIndexed { i, opt -> + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .background(kahoot[i % 4]) + .clickable(enabled = !answered) { + answered = true + if (i == q.int("correct")) score++ + } + .padding(16.dp), + ) { Text(opt, color = p.onAccent, fontWeight = FontWeight.SemiBold) } + } + if (answered) { + Meaning(q.str("expl"), p, true) + Spacer(Modifier.height(8.dp)) + PrimaryButton(if (idx + 1 >= qs.size) vm.tr("lit_show_result") else vm.tr("lit_next"), p) { + if (idx + 1 >= qs.size) { + finished = true + vm.markBookQuiz(id) + } else { + idx++ + answered = false + } + } + } + } + } +} + +@Composable +fun PlotScreen(vm: AppViewModel, id: String) { + val book = vm.content.book(id) ?: return + val spec = J(org.json.JSONObject().apply { + put("type", "assembly") + put("title", vm.tr(book.str("plotTitle"))) + put("sub", book.str("plotSub")) + put("items", book.o.optJSONArray("plot")) + put("meanings", book.o.optJSONArray("plotMeaning")) + }) + DispatchExercise(vm, spec, vm.tr(book.str("plotTitle"))) { vm.markBookPlot(id) } +} diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Icons.kt b/android/app/src/main/java/org/maturita/maturita/ui/Icons.kt new file mode 100644 index 0000000..fefa819 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/ui/Icons.kt @@ -0,0 +1,241 @@ +package org.maturita.maturita.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.maturita.maturita.data.Palette +@Composable +fun DrawnIcon(iconSize: Dp, modifier: Modifier = Modifier, onDraw: androidx.compose.ui.graphics.drawscope.DrawScope.() -> Unit) { + Canvas(modifier.size(iconSize)) { onDraw() } +} + +@Composable +fun BackIcon(color: Color, iconSize: Dp = 16.dp) { + DrawnIcon(iconSize) { + val p = Path().apply { + moveTo(size.width * 0.68f, size.height * 0.18f) + lineTo(size.width * 0.28f, size.height * 0.5f) + lineTo(size.width * 0.68f, size.height * 0.82f) + } + drawPath(p, color, style = Stroke(width = size.minDimension * 0.16f, cap = StrokeCap.Round)) + } +} + +@Composable +fun SearchIcon(color: Color, iconSize: Dp = 18.dp) { + DrawnIcon(iconSize) { + val r = size.minDimension * 0.28f + drawCircle(color, r, Offset(size.width * 0.42f, size.height * 0.42f), style = Stroke(size.minDimension * 0.12f)) + drawLine(color, Offset(size.width * 0.62f, size.height * 0.62f), Offset(size.width * 0.82f, size.height * 0.82f), size.minDimension * 0.12f, StrokeCap.Round) + } +} + +@Composable +fun StatsIcon(color: Color, iconSize: Dp = 18.dp) { + DrawnIcon(iconSize) { + val w = size.minDimension * 0.16f + drawRoundRect(color, Offset(size.width * 0.18f, size.height * 0.48f), Size(w, size.height * 0.36f), androidx.compose.ui.geometry.CornerRadius(w)) + drawRoundRect(color, Offset(size.width * 0.42f, size.height * 0.22f), Size(w, size.height * 0.62f), androidx.compose.ui.geometry.CornerRadius(w)) + drawRoundRect(color, Offset(size.width * 0.66f, size.height * 0.36f), Size(w, size.height * 0.48f), androidx.compose.ui.geometry.CornerRadius(w)) + } +} + +@Composable +fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) { + DrawnIcon(iconSize) { + val c = Offset(size.width / 2, size.height / 2) + drawCircle(color, size.minDimension * 0.16f, c, style = Stroke(size.minDimension * 0.1f)) + for (i in 0 until 6) { + val a = i * Math.PI / 3.0 + val x = (c.x + kotlin.math.cos(a) * size.minDimension * 0.34).toFloat() + val y = (c.y + kotlin.math.sin(a) * size.minDimension * 0.34).toFloat() + drawCircle(color, size.minDimension * 0.07f, Offset(x, y)) + } + } +} + +@Composable +fun LockIcon(color: Color, iconSize: Dp = 18.dp) { + DrawnIcon(iconSize) { + val body = Path().apply { + addRoundRect( + androidx.compose.ui.geometry.RoundRect( + size.width * 0.28f, size.height * 0.46f, + size.width * 0.72f, size.height * 0.82f, + androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.08f), + ), + ) + } + drawPath(body, color) + drawArc( + color, + 200f, 140f, false, + Offset(size.width * 0.32f, size.height * 0.18f), + Size(size.width * 0.36f, size.height * 0.4f), + style = Stroke(size.minDimension * 0.1f, cap = StrokeCap.Round), + ) + } +} + +@Composable +fun CheckIcon(color: Color, iconSize: Dp = 16.dp) { + DrawnIcon(iconSize) { + val p = Path().apply { + moveTo(size.width * 0.18f, size.height * 0.52f) + lineTo(size.width * 0.4f, size.height * 0.74f) + lineTo(size.width * 0.84f, size.height * 0.26f) + } + drawPath(p, color, style = Stroke(size.minDimension * 0.16f, cap = StrokeCap.Round)) + } +} + +@Composable +fun WifiIcon(color: Color, iconSize: Dp = 28.dp) { + DrawnIcon(iconSize) { + val c = Offset(size.width / 2, size.height * 0.72f) + drawCircle(color, size.minDimension * 0.06f, c) + for (i in 1..3) { + val r = size.minDimension * (0.16f + i * 0.16f) + drawArc(color, 210f, 120f, false, Offset(c.x - r, c.y - r), Size(r * 2, r * 2), style = Stroke(size.minDimension * 0.08f, cap = StrokeCap.Round)) + } + } +} + +@Composable +fun ChipIcon(color: Color, iconSize: Dp = 28.dp) { + DrawnIcon(iconSize) { + val m = size.minDimension * 0.22f + drawRoundRect(color, Offset(m, m), Size(size.width - 2 * m, size.height - 2 * m), androidx.compose.ui.geometry.CornerRadius(6f), style = Stroke(size.minDimension * 0.08f)) + val pin = size.minDimension * 0.08f + for (i in 0 until 3) { + val t = m + (size.height - 2 * m) * (i + 1) / 4f + drawLine(color, Offset(m - pin, t), Offset(m, t), size.minDimension * 0.06f) + drawLine(color, Offset(size.width - m, t), Offset(size.width - m + pin, t), size.minDimension * 0.06f) + } + } +} + +@Composable +fun GermanFlag(iconSize: Dp = 48.dp) { + DrawnIcon(iconSize) { + val w = this.size.width + val h = this.size.height + val clip = Path().apply { addOval(androidx.compose.ui.geometry.Rect(0f, 0f, w, h)) } + clipPath(clip) { + val band = h / 3f + drawRect(Color(0xFF2E2E30), size = Size(w, band)) + drawRect(Color(0xFFB8453D), topLeft = Offset(0f, band), size = Size(w, band)) + drawRect(Color(0xFFD6B352), topLeft = Offset(0f, 2 * band), size = Size(w, band)) + } + } +} + +@Composable +fun CzechFlag(iconSize: Dp = 48.dp) { + DrawnIcon(iconSize) { + val w = this.size.width + val h = this.size.height + val clip = Path().apply { addOval(androidx.compose.ui.geometry.Rect(0f, 0f, w, h)) } + clipPath(clip) { + drawRect(Color(0xFFFAFAFA), size = Size(w, h)) + drawRect(Color(0xFFD4292E), topLeft = Offset(0f, h / 2), size = Size(w, h / 2)) + val tri = Path().apply { + moveTo(0f, 0f) + lineTo(w * 0.55f, h / 2) + lineTo(0f, h) + close() + } + drawPath(tri, Color(0xFF123D8C)) + } + } +} + +@Composable +fun BookIcon(color: Color, iconSize: Dp = 22.dp) { + DrawnIcon(iconSize) { + drawRoundRect(color, Offset(size.width * 0.22f, size.height * 0.18f), Size(size.width * 0.56f, size.height * 0.64f), androidx.compose.ui.geometry.CornerRadius(4f), style = Stroke(size.minDimension * 0.08f)) + drawLine(color, Offset(size.width * 0.38f, size.height * 0.18f), Offset(size.width * 0.38f, size.height * 0.82f), size.minDimension * 0.08f) + } +} + +@Composable +fun QuizIcon(color: Color, iconSize: Dp = 22.dp) { + DrawnIcon(iconSize) { + drawCircle(color, size.minDimension * 0.36f, Offset(size.width / 2, size.height / 2), style = Stroke(size.minDimension * 0.08f)) + drawCircle(color, size.minDimension * 0.06f, Offset(size.width / 2, size.height * 0.68f)) + drawLine(color, Offset(size.width / 2, size.height * 0.32f), Offset(size.width / 2, size.height * 0.52f), size.minDimension * 0.08f, StrokeCap.Round) + } +} + +@Composable +fun OrderIcon(color: Color, iconSize: Dp = 22.dp) { + DrawnIcon(iconSize) { + for (i in 0 until 3) { + val y = size.height * (0.28f + i * 0.22f) + drawLine(color, Offset(size.width * 0.22f, y), Offset(size.width * 0.78f, y), size.minDimension * 0.08f, StrokeCap.Round) + } + } +} + +@Composable +fun GlobeIcon(color: Color, iconSize: Dp = 22.dp) { + DrawnIcon(iconSize) { + drawCircle(color, size.minDimension * 0.34f, center, style = Stroke(size.minDimension * 0.08f)) + drawOval(color, Offset(size.width * 0.32f, size.height * 0.18f), Size(size.width * 0.36f, size.height * 0.64f), style = Stroke(size.minDimension * 0.06f)) + } +} + +@Composable +fun PeopleIcon(color: Color, iconSize: Dp = 22.dp) { + DrawnIcon(iconSize) { + drawCircle(color, size.minDimension * 0.12f, Offset(size.width * 0.35f, size.height * 0.32f)) + drawCircle(color, size.minDimension * 0.12f, Offset(size.width * 0.65f, size.height * 0.32f)) + drawArc(color, 200f, 140f, false, Offset(size.width * 0.18f, size.height * 0.42f), Size(size.width * 0.34f, size.height * 0.4f), style = Stroke(size.minDimension * 0.08f, cap = StrokeCap.Round)) + drawArc(color, 200f, 140f, false, Offset(size.width * 0.48f, size.height * 0.42f), Size(size.width * 0.34f, size.height * 0.4f), style = Stroke(size.minDimension * 0.08f, cap = StrokeCap.Round)) + } +} + +@Composable +fun BulbIcon(color: Color, iconSize: Dp = 22.dp) { + DrawnIcon(iconSize) { + drawCircle(color, size.minDimension * 0.22f, Offset(size.width / 2, size.height * 0.4f), style = Stroke(size.minDimension * 0.08f)) + drawRoundRect(color, Offset(size.width * 0.4f, size.height * 0.58f), Size(size.width * 0.2f, size.height * 0.2f), androidx.compose.ui.geometry.CornerRadius(3f)) + } +} + +@Composable +fun FinishFlag(p: Palette, number: String, iconSize: Dp) { + DrawnIcon(iconSize) { + val r = size.minDimension / 2f - 2f + drawCircle(p.finishDark, r) + drawCircle(p.finishStroke, r, style = Stroke(2f)) + val cell = 6f + val fx = center.x - 1.5f * cell + val fy = center.y + 8f + for (iy in 0 until 2) for (ix in 0 until 3) { + val c = if ((ix + iy) and 1 == 1) p.finishLight else p.finishStroke + drawRect(c, Offset(fx + ix * cell, fy + iy * cell), Size(cell, cell)) + } + } +} + +fun noteIcon(name: String, color: Color): @Composable () -> Unit = { + when (name) { + "globe" -> GlobeIcon(color) + "people" -> PeopleIcon(color) + "bulb" -> BulbIcon(color) + "quiz" -> QuizIcon(color) + "order" -> OrderIcon(color) + else -> BookIcon(color) + } +} diff --git a/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt b/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt new file mode 100644 index 0000000..d2a8264 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt @@ -0,0 +1,236 @@ +package org.maturita.maturita.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.maturita.maturita.data.Palette +import kotlin.math.PI +import kotlin.math.min +import kotlin.math.sin + +data class MapNode( + val id: String, + val label: String, + val locked: Boolean, + val done: Boolean, + val current: Boolean, + val finish: Boolean = false, + val onClick: (() -> Unit)? = null, +) + +data class NodePos(val x: Float, val y: Float) + +data class Serpentine( + val points: List, + val width: Float, + val height: Float, + val litUntil: Float, +) + +fun serpentine( + n: Int, + avail: Float, + mx: Float, + my: Float, + spac: Float, + gap: Float, + wave: Float, + lift: Float = 0f, + minScale: Float = 0.55f, +): Serpentine { + if (n < 1) return Serpentine(emptyList(), avail, my * 2, 0f) + val scale = min(1f, maxOf(minScale, avail / (2 * mx + maxOf(n - 1, 0) * spac))) + val smx = mx * scale + val smy = my * scale + val sspac = spac * scale + val sgap = gap * scale + val swave = wave * scale + val slift = lift * scale + var rows = 1 + while (rows <= n) { + val cols = (n + rows - 1) / rows + if (2 * smx + (cols - 1) * sspac <= avail + 1) break + rows++ + } + rows = min(rows, n) + val cols = maxOf(1, (n + rows - 1) / rows) + val cw = 2 * smx + (cols - 1) * sspac + val ch = 2 * smy + (rows - 1) * sgap + slift + val phase = (2.0 * PI * 1.7 / maxOf(n - 1, 1)).toFloat() + val pts = MutableList(n) { NodePos(0f, 0f) } + for (r in 0 until rows) { + val base = r * cols + val len = min(cols, n - base) + val fwd = r % 2 == 0 + for (c in 0 until len) { + val i = base + c + val cc = if (fwd) c else cols - 1 - c + pts[i] = NodePos( + smx + cc * sspac, + smy + slift + r * sgap + swave * sin(i * phase), + ) + } + } + return Serpentine(pts, cw, ch, 0f) +} + +private fun catmull(pts: List, t: Float): NodePos { + val n = pts.size + if (n == 1) return pts[0] + var k = t.toInt() + var u = t - k + if (k < 0) { k = 0; u = 0f } + if (k >= n - 1) { k = n - 2; u = 1f } + val p1 = pts[k] + val p2 = pts[k + 1] + val p0 = if (k - 1 >= 0) pts[k - 1] else NodePos(p1.x - (p2.x - p1.x), p1.y - (p2.y - p1.y)) + val p3 = if (k + 2 < n) pts[k + 2] else NodePos(p2.x + (p2.x - p1.x), p2.y + (p2.y - p1.y)) + val u2 = u * u + val u3 = u2 * u + val x = 0.5f * (2 * p1.x + (-p0.x + p2.x) * u + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * u2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * u3) + val y = 0.5f * (2 * p1.y + (-p0.y + p2.y) * u + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * u2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * u3) + return NodePos(x, y) +} + +@Composable +fun PathMap( + nodes: List, + palette: Palette, + litUntil: Float, + nodeSize: Float = 72f, + mx: Float = 90f, + my: Float = 110f, + spac: Float = 170f, + gap: Float = 170f, + wave: Float = 28f, + lift: Float = 0f, + extra: List Unit>> = emptyList(), + extraPoints: List = emptyList(), +) { + if (nodes.isEmpty()) return + val density = LocalDensity.current + fun Float.toDp(): androidx.compose.ui.unit.Dp = with(density) { this@toDp.toDp() } + BoxWithConstraints(Modifier.fillMaxSize()) { + val avail = constraints.maxWidth.toFloat() + val geo = serpentine(nodes.size, avail, mx, my, spac, gap, wave, lift) + Box( + Modifier + .horizontalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), + ) { + Box(Modifier.width(geo.width.toDp()).height((geo.height + 80f).toDp())) { + Canvas(Modifier.fillMaxSize()) { + if (geo.points.size < 2) return@Canvas + fun pathBetween(t0: Float, t1: Float): Path { + val p = Path() + val steps = maxOf(1, ((t1 - t0) * 48).toInt()) + val start = catmull(geo.points, t0) + p.moveTo(start.x, start.y) + for (s in 1..steps) { + val t = t0 + (t1 - t0) * s / steps + val pt = catmull(geo.points, t) + p.lineTo(pt.x, pt.y) + } + return p + } + val end = (nodes.size - 1).toFloat() + drawPath(pathBetween(0f, end), palette.rail, style = Stroke(12f, cap = StrokeCap.Round, join = StrokeJoin.Round)) + if (litUntil > 0f) { + drawPath(pathBetween(0f, litUntil), palette.accent, style = Stroke(12f, cap = StrokeCap.Round, join = StrokeJoin.Round)) + } + extraPoints.forEach { } + } + nodes.forEachIndexed { i, node -> + val pt = geo.points[i] + Box( + Modifier + .offset { IntOffset((pt.x - nodeSize / 2).toInt(), (pt.y - nodeSize / 2).toInt()) } + .size(nodeSize.toDp()), + contentAlignment = Alignment.Center, + ) { + val bg = when { + node.locked -> palette.lockedBg + node.current || node.done -> palette.accent + else -> palette.node + } + val fg = when { + node.locked -> palette.overlay + node.current || node.done -> palette.onAccent + else -> palette.text + } + Box( + Modifier + .fillMaxSize() + .clip(CircleShape) + .background(bg) + .then( + if (node.locked) Modifier.border(1.dp, palette.lockedBorder, CircleShape) + else Modifier + ) + .then(if (!node.locked && node.onClick != null) Modifier.clickable { node.onClick.invoke() } else Modifier), + contentAlignment = Alignment.Center, + ) { + if (node.finish) { + FinishFlag(palette, "${i + 1}", nodeSize.toDp()) + } else { + androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("${i + 1}", color = fg, fontWeight = FontWeight.Black, fontSize = 18.sp) + if (node.locked) LockIcon(palette.overlay, 14.dp) + else if (node.done) CheckIcon(palette.onAccent, 14.dp) + } + } + } + } + Text( + node.label, + color = if (node.locked) palette.overlay else palette.text, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + modifier = Modifier + .offset { IntOffset((pt.x - 70).toInt(), (pt.y + nodeSize / 2 + 8).toInt()) } + .width(140.dp), + ) + } + extra.forEach { (pt, content) -> + Box(Modifier.offset { IntOffset(pt.x.toInt(), pt.y.toInt()) }) { content() } + } + } + } + } +} + +fun branchPoint(points: List, index: Int = 1, dx: Float = 40f, lift: Float = 110f): NodePos? { + if (index !in points.indices) return null + val p = points[index] + return NodePos(p.x + dx, p.y - lift) +} diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt b/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt new file mode 100644 index 0000000..16c82e9 --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt @@ -0,0 +1,637 @@ +package org.maturita.maturita.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.maturita.maturita.AppViewModel +import org.maturita.maturita.Route +import org.maturita.maturita.data.ColorMode +import org.maturita.maturita.data.J +import org.maturita.maturita.data.ProgressSum +import org.maturita.maturita.data.ThemeId +import org.maturita.maturita.data.UiLang +import org.maturita.maturita.data.normalizeAnswer +import org.maturita.maturita.data.summarize +import org.maturita.maturita.data.themeNames +import org.maturita.maturita.data.themePalette + +@Composable +fun WelcomeScreen(vm: AppViewModel) { + val p = vm.palette + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + ) { + Text("maturita.c", color = p.text, fontWeight = FontWeight.Bold, fontSize = 15.sp) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(vm.tr("welcome_title"), color = p.text, fontSize = 36.sp, fontWeight = FontWeight.Bold, lineHeight = 40.sp) + Spacer(Modifier.height(12.dp)) + Text( + vm.tr("welcome_body_android"), + color = p.subtext, + fontSize = 16.sp, + ) + Spacer(Modifier.height(22.dp)) + PrimaryButton(vm.tr("continue"), p) { vm.go(Route.Subjects) } + } + Spacer(Modifier.width(12.dp)) + Box( + Modifier + .size(108.dp) + .clip(CircleShape) + .background(p.mantle) + .border(1.dp, p.text.copy(0.08f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(vm.tr("welcome_stat_value"), color = p.text, fontSize = 28.sp, fontWeight = FontWeight.Bold) + Text(vm.tr("welcome_stat_label"), color = p.subtext, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center) + } + } + } + Spacer(Modifier.height(28.dp)) + FeatureCard(vm, "welcome_feat1_title", "welcome_feat1_body", "welcome_feat1_link") + Spacer(Modifier.height(12.dp)) + FeatureCard(vm, "welcome_feat2_title", "welcome_feat2_body", "welcome_feat2_link") + } +} + +@Composable +private fun FeatureCard(vm: AppViewModel, t: String, b: String, l: String) { + val p = vm.palette + CardBox(p, Modifier.fillMaxWidth()) { + Column { + Text(vm.tr(t), color = p.text, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(6.dp)) + Text(vm.tr(b), color = p.subtext, fontSize = 15.sp) + Spacer(Modifier.height(10.dp)) + PillButton(vm.tr(l), p) { vm.go(Route.Subjects) } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SubjectsScreen(vm: AppViewModel) { + val p = vm.palette + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, vm.tr("subjects_title"), vm.tr("subjects_sub")) + androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) { + val cell = (maxWidth - 12.dp) / 2 + FlowRow( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 24.dp), + maxItemsInEachRow = 2, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + vm.content.subjects.forEach { s -> + val open = s.bool("open") + Box( + Modifier + .width(cell) + .height(132.dp) + .clip(RoundedCornerShape(20.dp)) + .background(p.mantle) + .border(1.dp, if (open) p.text.copy(0.08f) else p.text.copy(0.04f), RoundedCornerShape(20.dp)) + .alpha(if (open) 1f else 0.48f) + .then(if (open) Modifier.clickable { vm.goPage(s.str("target")) } else Modifier) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + when (s.str("icon")) { + "de" -> GermanFlag(44.dp) + "wifi" -> WifiIcon(p.text, 36.dp) + "chip" -> ChipIcon(p.text, 36.dp) + "cz" -> CzechFlag(44.dp) + else -> LockIcon(p.overlay, 22.dp) + } + Spacer(Modifier.height(10.dp)) + Text(vm.tr(s.str("key")), color = p.text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center) + } + } + } + } + } + } +} + +@Composable +fun SettingsSheet(vm: AppViewModel) { + val p = vm.palette + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + ) { + Text(vm.tr("settings_title"), color = p.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(16.dp)) + Text(vm.tr("mode"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(6.dp)) + Segmented(p) { + SegChip(vm.tr("mode_dark"), vm.mode == ColorMode.Dark, p, Modifier.weight(1f)) { vm.applyMode(ColorMode.Dark) } + SegChip(vm.tr("mode_light"), vm.mode == ColorMode.Light, p, Modifier.weight(1f)) { vm.applyMode(ColorMode.Light) } + } + Spacer(Modifier.height(16.dp)) + Text(vm.tr("theme"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + themeNames.forEachIndexed { i, name -> + val id = ThemeId.entries[i] + val sw = themePalette(id, vm.mode) + val sel = vm.themeId == id + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (sel) p.surface1 else p.surface0) + .clickable { vm.setTheme(id) } + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Canvas(Modifier.size(36.dp)) { + drawCircle(sw.base) + drawCircle(sw.accent, 5f, Offset(size.width * 0.28f, size.height * 0.5f)) + drawCircle(sw.accent3, 5f, Offset(size.width * 0.5f, size.height * 0.5f)) + drawCircle(sw.success, 5f, Offset(size.width * 0.72f, size.height * 0.5f)) + if (sel) drawCircle(p.accent, size.minDimension / 2 - 1f, style = androidx.compose.ui.graphics.drawscope.Stroke(2.2f)) + } + Spacer(Modifier.width(10.dp)) + Text(name, color = p.text, fontWeight = FontWeight.Medium) + } + Spacer(Modifier.height(6.dp)) + } + Spacer(Modifier.height(10.dp)) + Text(vm.tr("language"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(6.dp)) + Segmented(p) { + SegChip("Čeština", vm.lang == UiLang.Cs, p, Modifier.weight(1f)) { vm.applyLang(UiLang.Cs) } + SegChip("English", vm.lang == UiLang.En, p, Modifier.weight(1f)) { vm.applyLang(UiLang.En) } + } + Spacer(Modifier.height(16.dp)) + Text(vm.tr("updates"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(6.dp)) + Text( + vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())), + color = p.subtext, + fontSize = 14.sp, + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PillButton(vm.tr("update_check"), p) { vm.checkUpdate(true) } + if (vm.update.canInstall) PrimaryButton(vm.tr("update_install"), p) { vm.installUpdate() } + } + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +fun StatsScreen(vm: AppViewModel) { + val p = vm.palette + val sum = summarize(vm.content, vm.progress) + val pct = if (sum.totalEx == 0) 0 else (100 * sum.doneEx / sum.totalEx) + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, vm.tr("stats_title"), vm.tr("stats_sub")) + Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Metric(p, vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx), Modifier.weight(1f)) + Metric(p, vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", pct), Modifier.weight(1f)) + Metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits), Modifier.weight(1f)) + } + Spacer(Modifier.height(10.dp)) + LinearProgressIndicator(pct / 100f, Modifier.fillMaxWidth().height(8.dp).clip(CircleShape), p.accent, p.surface1, StrokeCap.Round) + Spacer(Modifier.height(20.dp)) + Text(vm.tr("stats_section"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + vm.content.subjects.forEach { s -> + val open = s.bool("open") + val part = subjectSum(vm, s) + CardBox(p, Modifier.fillMaxWidth().padding(bottom = 10.dp).alpha(if (open) 1f else 0.55f)) { + Column { + Text(vm.tr(s.str("key")), color = p.text, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(4.dp)) + Text( + if (open) vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) else vm.tr("stats_locked"), + color = p.subtext, + fontSize = 13.sp, + ) + if (open && part.totalEx > 0) { + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( + part.doneEx / part.totalEx.toFloat(), + Modifier.fillMaxWidth().height(6.dp).clip(CircleShape), + p.accent, p.surface1, StrokeCap.Round, + ) + } + } + } + } + } + } +} + +@Composable +private fun Metric(p: org.maturita.maturita.data.Palette, label: String, value: String, modifier: Modifier) { + CardBox(p, modifier) { + Column { + Text(label, color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text(value, color = p.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) + } + } +} + +private fun subjectSum(vm: AppViewModel, s: J): ProgressSum { + val key = s.str("key") + val sum = ProgressSum() + when (s.str("target")) { + "roadmap" -> vm.content.german.filter { it.bool("unlocked") }.forEach { u -> + val n = u.strs("names").size + sum.totalEx += n + sum.doneEx += (1..n).count { vm.progress.germanDone(u.int("id"), it) } + } + "netyears" -> { + sum.totalEx = vm.content.netLessons.size + sum.doneEx = vm.content.netLessons.count { vm.progress.netDone(it.int("id")) } + } + "hwmap" -> { + sum.totalEx = vm.content.hw.size + sum.doneEx = vm.content.hw.count { vm.progress.hwDone(it.int("id")) } + } + "czechmap" -> { + sum.totalEx = vm.content.mluvnice.size + sum.doneEx = vm.content.mluvnice.count { vm.progress.mluvDone(it.int("id")) } + } + } + return sum +} + +@Composable +fun SearchOverlay(vm: AppViewModel) { + val p = vm.palette + val q = normalizeAnswer(vm.searchQuery) + val hits = buildSearch(vm).filter { q.isEmpty() || normalizeAnswer(it.hay).contains(q) }.take(24) + Column( + Modifier + .fillMaxSize() + .background(p.base.copy(alpha = 0.96f)) + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .background(p.mantle) + .padding(14.dp), + ) { + if (vm.searchQuery.isEmpty()) Text(vm.tr("search_placeholder"), color = p.overlay) + BasicTextField( + vm.searchQuery, + { vm.searchQuery = it }, + textStyle = TextStyle(color = p.text, fontSize = 16.sp), + cursorBrush = SolidColor(p.accent), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + Spacer(Modifier.width(8.dp)) + PillButton(vm.tr("back"), p) { vm.searchOpen = false } + } + Spacer(Modifier.height(12.dp)) + if (hits.isEmpty()) { + Text(if (q.isEmpty()) vm.tr("search_hint") else vm.tr("search_empty"), color = p.subtext) + } else { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + hits.forEach { hit -> + CardBox(p, Modifier.fillMaxWidth().clickable { + if (!hit.locked) { + vm.goPage(hit.target) + vm.searchOpen = false + } + }.alpha(if (hit.locked) 0.5f else 1f)) { + Column { + Text(hit.title, color = p.text, fontWeight = FontWeight.SemiBold) + Text(hit.sub, color = p.subtext, fontSize = 13.sp) + } + } + } + } + } + } +} + +private data class Hit(val title: String, val sub: String, val target: String, val locked: Boolean, val hay: String) + +private fun buildSearch(vm: AppViewModel): List { + val out = mutableListOf() + fun add(title: String, sub: String, target: String, locked: Boolean = false, extra: String = "") { + out += Hit(title, sub, target, locked, "$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") + vm.content.subjects.forEach { s -> + add(vm.tr(s.str("key")), vm.tr("search_subject"), s.strOrNull("target") ?: "subjects", !s.bool("open")) + } + vm.content.german.forEach { u -> + val unlocked = u.bool("unlocked") + add(u.str("title"), vm.tr("search_unit"), u.strOrNull("page") ?: "roadmap", !unlocked, "deutsch") + if (unlocked) { + u.strs("names").forEachIndexed { i, name -> + 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") + } + } + vm.content.netLessons.forEach { l -> + 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")}") + } + vm.content.hw.forEach { l -> + 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") + vm.content.books.forEach { b -> + add(b.str("title"), vm.tr("search_book"), b.str("page")) + } + return out +} + +@Composable +fun RoadmapScreen(vm: AppViewModel) { + val p = vm.palette + val current = vm.content.german.indexOfFirst { u -> + u.bool("unlocked") && u.strs("names").indices.any { !vm.progress.germanDone(u.int("id"), it + 1) } + } + Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + PageTop(vm, { vm.back() }, vm.tr("roadmap_title"), vm.tr("roadmap_sub")) + PathMap( + nodes = vm.content.german.mapIndexed { i, u -> + val unlocked = u.bool("unlocked") + val names = u.strs("names") + val done = unlocked && names.isNotEmpty() && names.indices.all { vm.progress.germanDone(u.int("id"), it + 1) } + MapNode( + id = u.str("page"), + label = u.str("title"), + locked = !unlocked, + done = done, + current = i == current, + finish = i == vm.content.german.lastIndex, + onClick = if (unlocked) ({ vm.go(Route.UnitMap(u.int("id"))) }) else null, + ) + }, + palette = p, + litUntil = 2f, + ) + } +} + +@Composable +fun UnitMapScreen(vm: AppViewModel, unitId: Int) { + val u = vm.content.germanUnit(unitId) ?: return + val p = vm.palette + val names = u.strs("names") + Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + PageTop(vm, { vm.back() }, u.str("title"), vm.tr(u.str("sub"))) + val next = names.indices.firstOrNull { !vm.progress.germanDone(unitId, it + 1) } + PathMap( + nodes = names.mapIndexed { i, name -> + val n = i + 1 + MapNode( + id = "e$n", + label = name, + locked = false, + done = vm.progress.germanDone(unitId, n), + current = i == next, + onClick = { vm.go(Route.GermanEx(unitId, n)) }, + ) + }, + palette = p, + litUntil = names.indices.count { vm.progress.germanDone(unitId, it + 1) }.toFloat().coerceAtMost((names.size - 1).toFloat()), + nodeSize = 58f, + spac = 140f, + mx = 70f, + lift = if (u.has("branch")) 110f else 0f, + ) + if (u.has("branch")) { + Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) { + PrimaryButton(u.str("vocab").let { vm.tr("Vokabeltraining") }, p) { vm.go(Route.Vocab(unitId)) } + } + } + } +} + +@Composable +fun NetYearsScreen(vm: AppViewModel) { + val p = vm.palette + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, vm.tr("net_years_title"), vm.tr("net_years_sub")) + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + YearCard(vm, vm.tr("net_year1"), vm.tr("net_sub"), true) { vm.go(Route.NetMap) } + listOf("net_year2", "net_year3", "net_year4").forEach { key -> + YearCard(vm, vm.tr(key), vm.tr("net_year_locked_sub"), false) {} + } + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun YearCard(vm: AppViewModel, title: String, sub: String, open: Boolean, onClick: () -> Unit) { + val p = vm.palette + CardBox(p, Modifier.fillMaxWidth().alpha(if (open) 1f else 0.48f).then(if (open) Modifier.clickable(onClick = onClick) else Modifier)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, color = p.text, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text(sub, color = p.subtext, fontSize = 14.sp) + } + if (!open) LockIcon(p.overlay) + } + } +} + +@Composable +fun LessonMapScreen( + vm: AppViewModel, + title: String, + sub: String, + nodes: List, + litUntil: Float, +) { + Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + PageTop(vm, { vm.back() }, title, sub) + PathMap(nodes, vm.palette, litUntil, nodeSize = 64f, spac = 150f, mx = 70f) + } +} + +@Composable +fun SlidesScreen( + vm: AppViewModel, + title: String, + sub: String, + slides: List, + onExercise: () -> Unit, +) { + val p = vm.palette + var idx = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(0) } + val slide = slides.getOrNull(idx.intValue) ?: return + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, title, sub) + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + Text(slide.str("kicker"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text(slide.str("title"), color = p.text, fontSize = 24.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)) + slide.strOrNull("tip")?.let { CardBox(p, Modifier.fillMaxWidth().padding(bottom = 12.dp)) { Text(it, color = p.text) } } + slide.strs("lines").forEach { line -> + CardBox(p, Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Text(line, color = p.text, fontSize = 16.sp) + } + } + } + Row(Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween) { + if (idx.intValue > 0) PillButton(vm.tr("net_slide_prev"), p) { idx.intValue-- } + else Spacer(Modifier.width(1.dp)) + if (idx.intValue < slides.lastIndex) PrimaryButton(vm.tr("net_slide_next"), p) { idx.intValue++ } + else PrimaryButton(vm.tr("net_slide_start"), p, onClick = onExercise) + } + } +} + +@Composable +fun CzechMapScreen(vm: AppViewModel) { + val p = vm.palette + val items = listOf( + Triple("L", vm.tr("Literatura"), null), + Triple("M", vm.tr("Mluvnice"), Route.Mluvnice as Route?), + Triple("Č", vm.tr("Maturitní četba"), Route.ReadingList as Route?), + ) + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, vm.tr("Český jazyk a literatura"), vm.tr("czech_sub")) + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + items.forEach { (letter, name, route) -> + CardBox(p, Modifier.fillMaxWidth().alpha(if (route == null) 0.48f else 1f).then( + if (route != null) Modifier.clickable { vm.go(route) } else Modifier + )) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(56.dp).clip(CircleShape).background(if (route == null) p.lockedBg else p.accent), + contentAlignment = Alignment.Center, + ) { + if (route == null) LockIcon(p.overlay) else Text(letter, color = p.onAccent, fontWeight = FontWeight.Black, fontSize = 20.sp) + } + Spacer(Modifier.width(14.dp)) + Text(name, color = p.text, fontWeight = FontWeight.SemiBold, fontSize = 16.sp) + } + } + } + } + } +} + +@Composable +fun BookListScreen(vm: AppViewModel) { + val p = vm.palette + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, vm.tr("Maturitní četba"), vm.tr("reading_sub")) + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + vm.content.books.forEach { b -> + CardBox(p, Modifier.fillMaxWidth().clickable { vm.go(Route.Book(b.str("id"))) }) { + Row(verticalAlignment = Alignment.CenterVertically) { + BookIcon(p.text, 28.dp) + Spacer(Modifier.width(12.dp)) + Column { + Text(b.str("title"), color = p.text, fontWeight = FontWeight.Bold) + Text(vm.tr(b.str("subKey")), color = p.subtext, fontSize = 13.sp) + } + } + } + } + } + } +} + +@Composable +fun BookScreen(vm: AppViewModel, id: String) { + val b = vm.content.book(id) ?: return + val p = vm.palette + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + PageTop(vm, { vm.back() }, b.str("title"), vm.tr(b.str("subKey"))) + Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + CardBox(p, Modifier.fillMaxWidth().clickable { vm.go(Route.BookQuiz(id)) }) { + Row(verticalAlignment = Alignment.CenterVertically) { + QuizIcon(p.text); Spacer(Modifier.width(10.dp)) + Column { + Text(vm.tr(b.str("quizTitle")), color = p.text, fontWeight = FontWeight.Bold) + Text(vm.tr(b.str("quizSub")), color = p.subtext, fontSize = 13.sp) + } + } + } + CardBox(p, Modifier.fillMaxWidth().clickable { vm.go(Route.BookPlot(id)) }) { + Row(verticalAlignment = Alignment.CenterVertically) { + OrderIcon(p.text); Spacer(Modifier.width(10.dp)) + Column { + Text(vm.tr(b.str("plotTitle")), color = p.text, fontWeight = FontWeight.Bold) + Text(vm.tr(b.str("plotSub")), color = p.subtext, fontSize = 13.sp) + } + } + } + b.arr("notes").forEach { note -> + CardBox(p, Modifier.fillMaxWidth()) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + noteIcon(note.str("icon"), p.text)() + Spacer(Modifier.width(8.dp)) + Text(note.str("title"), color = p.text, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(8.dp)) + note.strs("lines").forEach { + Text("• $it", color = p.text, fontSize = 15.sp, modifier = Modifier.padding(bottom = 4.dp)) + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/org/maturita/maturita/update/Updater.kt b/android/app/src/main/java/org/maturita/maturita/update/Updater.kt new file mode 100644 index 0000000..74205ab --- /dev/null +++ b/android/app/src/main/java/org/maturita/maturita/update/Updater.kt @@ -0,0 +1,109 @@ +package org.maturita.maturita.update + +import android.app.Application +import android.content.Intent +import android.os.Handler +import android.os.Looper +import androidx.core.content.FileProvider +import org.maturita.maturita.BuildConfig +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors + +data class UpdateState( + val status: String = "idle", + val remote: String? = null, + val messageKey: String = "update_current", + val messageArg: String? = BuildConfig.COMMIT.take(7), + val canInstall: Boolean = false, +) + +class Updater(private val app: Application) { + private val io = Executors.newSingleThreadExecutor() + private val main = Handler(Looper.getMainLooper()) + + private fun emit(cb: (UpdateState) -> Unit, state: UpdateState) { + main.post { cb(state) } + } + + fun check(interactive: Boolean, cb: (UpdateState) -> Unit) { + if (BuildConfig.COMMIT == "dev") { + emit(cb, UpdateState(status = "dev", messageKey = "update_err_devbuild", messageArg = null)) + return + } + emit(cb, UpdateState(status = "checking", messageKey = "update_checking", messageArg = null)) + io.execute { + try { + val atom = get("https://github.com/${BuildConfig.UPDATE_REPO}/commits/${BuildConfig.UPDATE_BRANCH}.atom") + val sha = Regex(""".*?/commit/([0-9a-f]{7,40})""", RegexOption.IGNORE_CASE) + .find(atom)?.groupValues?.get(1) + ?: throw IllegalStateException("sha") + val version = get( + "https://raw.githubusercontent.com/${BuildConfig.UPDATE_REPO}/$sha/VERSION", + ).trim() + val local = BuildConfig.COMMIT + if (version.startsWith(local) || local.startsWith(version)) { + emit(cb, UpdateState("ok", version, "update_uptodate", version.take(7))) + } else { + emit(cb, UpdateState("available", version, "update_available", version.take(7), true)) + } + } catch (_: Exception) { + emit(cb, UpdateState("error", messageKey = "update_err_network", messageArg = null)) + } + } + } + + fun install(cb: (UpdateState) -> Unit) { + emit(cb, UpdateState(status = "downloading", messageKey = "update_downloading", messageArg = null)) + io.execute { + try { + val atom = get("https://github.com/${BuildConfig.UPDATE_REPO}/commits/${BuildConfig.UPDATE_BRANCH}.atom") + val sha = Regex(""".*?/commit/([0-9a-f]{7,40})""", RegexOption.IGNORE_CASE) + .find(atom)?.groupValues?.get(1) + ?: throw IllegalStateException("sha") + val url = "https://raw.githubusercontent.com/${BuildConfig.UPDATE_REPO}/$sha/maturita-android.apk" + val dir = File(app.cacheDir, "updates").apply { mkdirs() } + val apk = File(dir, "maturita.apk") + download(url, apk) + val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", apk) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + main.post { app.startActivity(intent) } + emit(cb, UpdateState("staged", sha, "update_staged", sha.take(7))) + } catch (_: Exception) { + emit(cb, UpdateState("error", messageKey = "update_err_download", messageArg = null)) + } + } + } + + private fun get(url: String): String { + val c = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15000 + readTimeout = 15000 + instanceFollowRedirects = true + } + return try { + if (c.responseCode !in 200..299) error("http ${c.responseCode}") + c.inputStream.bufferedReader().use { it.readText() } + } finally { + c.disconnect() + } + } + + private fun download(url: String, dest: File) { + val c = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 20000 + readTimeout = 60000 + instanceFollowRedirects = true + } + try { + if (c.responseCode !in 200..299) error("http ${c.responseCode}") + c.inputStream.use { input -> dest.outputStream().use { input.copyTo(it) } } + } finally { + c.disconnect() + } + } +} diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..b84a8e5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..b84a8e5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..9bd2735 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_fg.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_fg.png new file mode 100644 index 0000000..9bd2735 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_fg.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..9bd2735 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c089401 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_fg.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_fg.png new file mode 100644 index 0000000..c089401 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_fg.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..c089401 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..67770f5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_fg.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_fg.png new file mode 100644 index 0000000..67770f5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_fg.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..67770f5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4bf5a3b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_fg.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_fg.png new file mode 100644 index 0000000..4bf5a3b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_fg.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4bf5a3b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..e0360d2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_fg.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_fg.png new file mode 100644 index 0000000..e0360d2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_fg.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e0360d2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..040ac04 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FAF7F2 + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c3d8a64 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c9cc257 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..20cb258 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "9.1.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..ad07856 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +org.gradle.parallel=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8fa4a7a --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=60000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..d74e065 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,80 @@ +#!/bin/sh + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +############################################################################## + +app_path=$0 +while [ -h "$app_path" ]; do + APP_HOME=${app_path%"${app_path##*/}"} + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$APP_HOME$link ;; + esac +done +APP_HOME=$( cd "${app_path%"${app_path##*/}"}" && pwd -P ) || exit +APP_NAME="Gradle" +MAX_FD=maximum + +warn () { echo "$*"; } >&2 +die () { echo; echo "$*"; echo; exit 1; } >&2 + +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in + CYGWIN* ) cygwin=true ;; + Darwin* ) darwin=true ;; + MSYS* | MINGW* ) msys=true ;; + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME" + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 ; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH." + fi +fi + +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" ;; + esac + case $MAX_FD in + '' | soft) : ;; + *) ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" ;; + esac +fi + +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +set -- \ + "-Dorg.gradle.appname=gradlew" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..face9e9 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,22 @@ +@rem Gradle startup script for Windows +@if "%DEBUG%"=="" @echo off +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_HOME=%DIRNAME% +set APP_BASE_NAME=%~n0 +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" +if defined JAVA_HOME goto findJavaFromJavaHome +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +exit /b 1 +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe +if exist "%JAVA_EXE%" goto execute +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +exit /b 1 +:execute +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..6872875 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "maturita" +include(":app") diff --git a/scripts/extract-android-content.py b/scripts/extract-android-content.py new file mode 100644 index 0000000..901b040 --- /dev/null +++ b/scripts/extract-android-content.py @@ -0,0 +1,1288 @@ +#!/usr/bin/env python3 +"""Pull educational content out of the GTK C sources into content.json.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +OUT = ROOT / "android" / "app" / "src" / "main" / "assets" / "content.json" + +IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def strip_comments(src: str) -> str: + out = [] + i, n = 0, len(src) + while i < n: + c = src[i] + if c == '"' or c == "'": + q = c + out.append(c) + i += 1 + while i < n: + ch = src[i] + out.append(ch) + if ch == "\\" and i + 1 < n: + out.append(src[i + 1]) + i += 2 + continue + i += 1 + if ch == q: + break + continue + if c == "/" and i + 1 < n and src[i + 1] == "/": + i += 2 + while i < n and src[i] != "\n": + i += 1 + continue + if c == "/" and i + 1 < n and src[i + 1] == "*": + i += 2 + while i + 1 < n and not (src[i] == "*" and src[i + 1] == "/"): + i += 1 + i = min(n, i + 2) + out.append(" ") + continue + out.append(c) + i += 1 + return "".join(out) + + +def unescape(s: str) -> str: + out = [] + i = 0 + while i < len(s): + if s[i] == "\\" and i + 1 < len(s): + n = s[i + 1] + mapping = { + "n": "\n", + "t": "\t", + "r": "\r", + "\\": "\\", + '"': '"', + "'": "'", + } + if n in mapping: + out.append(mapping[n]) + i += 2 + continue + if n == "x": + j = i + 2 + hexdigits = "" + while j < len(s) and s[j] in "0123456789abcdefABCDEF" and len(hexdigits) < 2: + hexdigits += s[j] + j += 1 + out.append(chr(int(hexdigits, 16)) if hexdigits else "x") + i = j + continue + out.append(s[i]) + i += 1 + return "".join(out) + + +class Tok: + def __init__(self, kind: str, value, pos: int): + self.kind = kind + self.value = value + self.pos = pos + + +def tokenize(src: str) -> list[Tok]: + toks: list[Tok] = [] + i, n = 0, len(src) + while i < n: + c = src[i] + if c.isspace(): + i += 1 + continue + if c == '"': + i += 1 + raw = [] + while i < n: + ch = src[i] + if ch == "\\" and i + 1 < n: + raw.append(ch) + raw.append(src[i + 1]) + i += 2 + continue + if ch == '"': + i += 1 + break + raw.append(ch) + i += 1 + while i < n and src[i].isspace(): + i += 1 + while i < n and src[i] == '"': + i += 1 + while i < n: + ch = src[i] + if ch == "\\" and i + 1 < n: + raw.append(ch) + raw.append(src[i + 1]) + i += 2 + continue + if ch == '"': + i += 1 + break + raw.append(ch) + i += 1 + while i < n and src[i].isspace(): + i += 1 + toks.append(Tok("str", unescape("".join(raw)), i)) + continue + if c in "{}[],;=": + toks.append(Tok(c, c, i)) + i += 1 + continue + if c == "-" and i + 1 < n and src[i + 1].isdigit(): + j = i + 1 + while j < n and (src[j].isdigit() or src[j] == "."): + j += 1 + toks.append(Tok("num", int(float(src[i:j])), i)) + i = j + continue + if c.isdigit(): + j = i + while j < n and (src[j].isdigit() or src[j] == "."): + j += 1 + text = src[i:j] + toks.append(Tok("num", int(float(text)) if "." not in text else int(float(text)), i)) + i = j + continue + if c == "'" and i + 2 < n: + j = i + 1 + raw = [] + while j < n and src[j] != "'": + if src[j] == "\\" and j + 1 < n: + raw.append(src[j + 1]) + j += 2 + continue + raw.append(src[j]) + j += 1 + j += 1 + toks.append(Tok("num", ord(raw[0]) if raw else 0, i)) + i = j + continue + m = IDENT.match(src, i) + if m: + name = m.group(0) + i = m.end() + if name == "NULL": + toks.append(Tok("null", None, i)) + elif name in ("TRUE", "FALSE"): + toks.append(Tok("num", 1 if name == "TRUE" else 0, i)) + else: + toks.append(Tok("id", name, i)) + continue + i += 1 + return toks + + +def parse_value(toks: list[Tok], i: int): + if i >= len(toks): + return None, i + t = toks[i] + if t.kind == "str": + return t.value, i + 1 + if t.kind == "num": + return t.value, i + 1 + if t.kind == "null": + return None, i + 1 + if t.kind == "id": + name = t.value + i += 1 + if i < len(toks) and toks[i].kind == "(": + depth = 1 + i += 1 + while i < len(toks) and depth: + if toks[i].kind == "(": + depth += 1 + elif toks[i].kind == ")": + depth -= 1 + i += 1 + return None, i + return {"$ref": name}, i + if t.kind == "{": + return parse_list(toks, i) + return None, i + 1 + + +def parse_list(toks: list[Tok], i: int): + assert toks[i].kind == "{" + i += 1 + items = [] + while i < len(toks) and toks[i].kind != "}": + if toks[i].kind == ",": + i += 1 + continue + val, i = parse_value(toks, i) + items.append(val) + if i < len(toks) and toks[i].kind == ",": + i += 1 + if i < len(toks) and toks[i].kind == "}": + i += 1 + return items, i + + +ARRAY_RE = re.compile( + r"(?:static\s+)?const\s+" + r"(?:struct\s+)?(?P[A-Za-z_][A-Za-z0-9_]*)" + r"(?:\s+\*|\s+const\s+\*|\s*)\s*" + r"(?P[A-Za-z_][A-Za-z0-9_]*)" + r"(?:\s*\[[^\]]*\])+\s*=\s*\{", + re.M, +) + +CHARPTR_RE = re.compile( + r"(?:static\s+)?const\s+char\s*\*\s*(?:const\s+)?" + r"(?P[A-Za-z_][A-Za-z0-9_]*)" + r"(?:\s*\[[^\]]*\])+\s*=\s*\{", + re.M, +) + +STRING_ASSIGN_RE = re.compile( + r"(?:static\s+)?const\s+char\s*\*\s*" + r"(?P[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P\"(?:\\.|[^\"\\])*\"(?:\s*\"(?:\\.|[^\"\\])*\")*)\s*;", + re.S, +) + +FUNC_RE = re.compile( + r"^(?:static\s+)?(?:GtkWidget\s*\*|void|gboolean|int|char\s*\*)\s*" + r"([A-Za-z_][A-Za-z0-9_]*)\s*\(", + re.M, +) + + +def find_arrays(src: str) -> dict[str, tuple[str, list]]: + found: dict[str, tuple[str, list]] = {} + funcs = [(m.start(), m.group(1)) for m in FUNC_RE.finditer(src)] + + def func_at(pos: int) -> str | None: + current = None + for start, name in funcs: + if start < pos: + current = name + else: + break + if current is None: + return None + # Only treat as local if the array sits after the function's opening brace + # and before the next top-level function. Good enough for this codebase. + return current + + def add(typ: str, name: str, pos_brace: int): + toks = tokenize(src[pos_brace:]) + if not toks or toks[0].kind != "{": + return + val, _ = parse_list(toks, 0) + fn = func_at(pos_brace) + key = f"{fn}.{name}" if fn and name in { + "slides", "qs", "about", "world", "people", "terms", "persons", + "nouns", "ans", + } else name + # Prefer the first global, overwrite locals with prefixed names. + if key in found and not key.startswith(fn or "\0"): + return + found[key] = (typ, val) + + for m in ARRAY_RE.finditer(src): + add(m.group("type"), m.group("name"), m.end() - 1) + for m in CHARPTR_RE.finditer(src): + add("char*", m.group("name"), m.end() - 1) + for m in STRING_ASSIGN_RE.finditer(src): + toks = tokenize(m.group("body")) + text = "".join(t.value for t in toks if t.kind == "str") + found[m.group("name")] = ("char", [text]) + return found + + +def as_list(v): + return v if isinstance(v, list) else [] + + +def as_str(v) -> str | None: + if v is None: + return None + if isinstance(v, str): + return v + return None + + +def resolve(arrays: dict, val, depth=0): + if depth > 8: + return val + if isinstance(val, dict) and "$ref" in val: + name = val["$ref"] + if name in arrays: + return resolve(arrays, arrays[name][1], depth + 1) + return None + if isinstance(val, list): + return [resolve(arrays, x, depth + 1) for x in val] + return val + + +def choice_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 4: + continue + opts = [o for o in as_list(row[1]) if isinstance(o, str)] + correct = row[3] if isinstance(row[3], int) else 0 + out.append({ + "prompt": as_str(row[0]) or "", + "options": opts, + "correct": correct, + }) + return out + + +def typed_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 2: + continue + out.append({ + "prompt": as_str(row[0]) or "", + "answers": as_str(row[1]) or "", + "meaning": as_str(row[2]) if len(row) > 2 else None, + }) + return out + + +def assembly_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 2: + continue + words = [w for w in as_list(row[1]) if isinstance(w, str)] + n = row[2] if len(row) > 2 and isinstance(row[2], int) else len(words) + out.append({ + "prompt": as_str(row[0]), + "words": words[:n], + }) + return out + + +def str_list(rows) -> list[str]: + return [x for x in as_list(rows) if isinstance(x, str)] + + +def verb_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 3: + continue + out.append({ + "before": as_str(row[0]) or "", + "after": as_str(row[1]) or "", + "answer": as_str(row[2]) or "", + }) + return out + + +def verb_clue_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 4: + continue + out.append({ + "emoji": as_str(row[0]) or "", + "before": as_str(row[1]) or "", + "after": as_str(row[2]) or "", + "answer": as_str(row[3]) or "", + }) + return out + + +def assign_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 3: + continue + out.append({ + "emoji": as_str(row[0]), + "label": as_str(row[1]) or "", + "group": row[2] if isinstance(row[2], int) else 0, + }) + return out + + +def free_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 4: + continue + out.append({ + "question": as_str(row[0]) or "", + "qCs": as_str(row[1]) or "", + "sample": as_str(row[2]) or "", + "aCs": as_str(row[3]) or "", + }) + return out + + +def profile_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 2: + continue + out.append({ + "stem": as_str(row[0]) or "", + "sample": as_str(row[1]) or "", + }) + return out + + +def slides_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 4: + continue + lines = [x for x in as_list(row[3]) if isinstance(x, str)] + out.append({ + "kicker": as_str(row[0]) or "", + "title": as_str(row[1]) or "", + "tip": as_str(row[2]), + "lines": lines, + }) + return out + + +def fill_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 9: + continue + segs = [as_str(row[1]), as_str(row[3]), as_str(row[5]), as_str(row[7])] + ans = [as_str(row[2]), as_str(row[4]), as_str(row[6])] + out.append({ + "num": as_str(row[0]), + "segs": [s or "" for s in segs], + "answers": [a for a in ans if a], + "mean": as_str(row[8]), + }) + return out + + +def let_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 11: + continue + segs = [as_str(row[i]) or "" for i in (1, 3, 5, 7, 9)] + ans = [as_str(row[i]) for i in (2, 4, 6, 8)] + out.append({ + "num": as_str(row[0]), + "segs": segs, + "answers": [a for a in ans if a], + "mean": as_str(row[10]), + }) + return out + + +def ex4row_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 6: + continue + segs = [s for s in as_list(row[0]) if s is None or isinstance(s, str)] + ans = [a for a in as_list(row[1]) if isinstance(a, str)] + words = [w for w in as_list(row[3]) if isinstance(w, str)] if len(row) > 3 else [] + gmean = [g for g in as_list(row[4]) if isinstance(g, str)] if len(row) > 4 else [] + gaps = row[5] if len(row) > 5 and isinstance(row[5], int) else len(ans) + out.append({ + "segs": [s or "" for s in segs], + "answers": ans[:gaps], + "czech": as_str(row[2]) if len(row) > 2 else None, + "words": words[:gaps], + "gmean": gmean[:gaps], + }) + return out + + +def kw_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 2: + continue + out.append({ + "prompt": as_str(row[0]) or "", + "answers": as_str(row[1]) or "", + "mean": as_str(row[2]) if len(row) > 2 else None, + "german": as_str(row[3]) if len(row) > 3 else None, + }) + return out + + +def mluv_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 5: + continue + nblank = row[2] if isinstance(row[2], int) else 1 + out.append({ + "prompt": as_str(row[0]) or "", + "shown": as_str(row[1]) or "", + "hints": [h for h in as_list(row[3]) if isinstance(h, str)][:nblank], + "answers": [a for a in as_list(row[4]) if isinstance(a, str)][:nblank], + }) + return out + + +def reveal_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 2: + continue + out.append({ + "prompt": as_str(row[0]) or "", + "solution": as_str(row[1]) or "", + }) + return out + + +def lit_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 4: + continue + opts = [o for o in as_list(row[1]) if isinstance(o, str)] + out.append({ + "prompt": as_str(row[0]) or "", + "options": opts, + "correct": row[2] if isinstance(row[2], int) else 0, + "expl": as_str(row[3]), + }) + return out + + +def trans_sections(rows, arrays) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 2: + continue + data = resolve(arrays, row[1]) + out.append({ + "header": as_str(row[0]) or "", + "rows": typed_items(data), + }) + return out + + +def dialogs(arrays) -> list[dict]: + out = [] + for key in ("ex1_d1", "ex1_d2", "ex1_d3"): + if key not in arrays: + continue + rows = [] + for row in as_list(arrays[key][1]): + row = as_list(row) + if len(row) < 4: + continue + rows.append({ + "speaker": as_str(row[0]) or "", + "before": as_str(row[1]) or "", + "answer": as_str(row[2]) or "", + "after": as_str(row[3]) or "", + }) + titles = {"ex1_d1": "Sich vorstellen", "ex1_d2": "Begrüßung", "ex1_d3": "Verabschiedung"} + out.append({"name": titles[key], "rows": rows}) + return out + + +def ordne_items(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 3: + continue + out.append({ + "mid": as_str(row[0]) or "", + "fw": as_str(row[1]) or "", + "ans": as_str(row[2]) or "", + }) + return out + + +def ex2_items(arrays) -> list[dict]: + out = [] + if "ex2_u3_items" not in arrays: + return out + for row in as_list(arrays["ex2_u3_items"][1]): + row = as_list(row) + if len(row) < 2: + continue + raw_rows = resolve(arrays, row[1]) + parsed = [] + for r in as_list(raw_rows): + r = as_list(r) + if len(r) < 4: + continue + segs = [s or "" for s in as_list(r[1]) if s is None or isinstance(s, str)] + ans = [a for a in as_list(r[2]) if isinstance(a, str)] + gaps = r[3] if isinstance(r[3], int) else len(ans) + parsed.append({ + "num": as_str(r[0]), + "segs": segs, + "answers": ans[:gaps], + }) + out.append({"czech": as_str(row[0]) or "", "rows": parsed}) + return out + + +def net_ans(rows) -> list[list[dict]]: + out = [] + for block in as_list(rows): + block = as_list(block) + inner = [] + for row in block: + row = as_list(row) + if len(row) < 5: + continue + inner.append({ + "pfx": row[0] if isinstance(row[0], int) else -1, + "net": as_str(row[1]), + "bcast": as_str(row[2]), + "lo": as_str(row[3]), + "hi": as_str(row[4]), + }) + out.append(inner) + return out + + +def net_tasks(rows) -> list[dict]: + out = [] + for row in as_list(rows): + row = as_list(row) + if len(row) < 2: + continue + out.append({"prompt": as_str(row[0]) or "", "solution": as_str(row[1]) or ""}) + return out + + +def parse_i18n(src: str) -> dict: + arrays = find_arrays(src) + out = {} + for name in ("tr_ui", "tr_content"): + if name not in arrays: + continue + for row in as_list(arrays[name][1]): + row = as_list(row) + if not row or not as_str(row[0]): + continue + key = as_str(row[0]) + cs = as_str(row[1]) if len(row) > 1 else None + en = as_str(row[2]) if len(row) > 2 else None + if key: + out[key] = {"cs": cs, "en": en} + return out + + +def combo(title, sub, pool, rows, meanings=None, kind="combo", **extra): + spec = { + "type": kind, + "title": title, + "sub": sub, + "pool": pool or [], + "rows": rows, + "meanings": meanings or [], + } + spec.update(extra) + return spec + + +def main() -> int: + arrays: dict[str, tuple[str, list]] = {} + i18n = {} + for path in sorted(SRC.glob("*.c")): + text = strip_comments(path.read_text(encoding="utf-8")) + arrays.update(find_arrays(text)) + if path.name == "i18n.c": + i18n = parse_i18n(text) + + def A(name): + if name not in arrays: + return [] + return resolve(arrays, arrays[name][1]) + + german_units = [ + { + "id": 0, + "title": "Neue Freunde", + "page": "unit1", + "tag": "u1", + "sub": "unit1_sub", + "unlocked": True, + "names": [ + "Dialog", "Sätze bilden", "Was ist richtig?", "Freie Antwort", + "Zahlen", "Wie viel?", "Zahlenreihe", "Verb einsetzen", + "Wer? Wie? Wo?", "Wörter trennen", "Grußformen", + "Was macht er/sie?", "Länder", + ], + "branch": "u1vocab", + "exercises": { + "1": combo("Dialog", "sub_dialog", str_list(A("ex1_pool")), + [], str_list(A("ex1_meaning")), "dialog", + dialogues=dialogs(arrays)), + "2": { + "type": "assembly", "title": "Sätze bilden", "sub": "sub_assembly", + "items": assembly_items(A("ex2_items")), + "meanings": str_list(A("ex2_meaning")), + }, + "3": { + "type": "choice", "title": "Was ist richtig?", "sub": "sub_choice_num", + "questions": choice_items(A("ex3_questions")), + "meanings": str_list(A("ex3_meaning")), + }, + "4": { + "type": "free", "title": "Freie Antwort", "sub": "sub_free", + "tip": "tip_ss", "questions": free_items(A("ex4_questions")), + }, + "5": combo("Zahlen", "sub_zahlen", str_list(A("ex5_pool")), + [{"digits": as_list(r)[0], "answer": as_list(r)[1]} + for r in as_list(A("ex5_data")) if len(as_list(r)) >= 2], + str_list(A("ex5_meaning")), "number"), + "6": combo("Wie viel?", "sub_wieviel", str_list(A("ex6_pool")), + [{"emoji": as_list(r)[0], "count": as_list(r)[1], + "noun": as_list(r)[2], "answer": as_list(r)[3]} + for r in as_list(A("ex6_data")) if len(as_list(r)) >= 4], + str_list(A("ex6_meaning")), "count"), + "7": combo("Zahlenreihe", "sub_reihe", str_list(A("ex7_pool")), + [{"before": as_list(r)[0], "after": as_list(r)[1], + "answer": as_list(r)[2]} + for r in as_list(A("ex7_data")) if len(as_list(r)) >= 3], + str_list(A("ex7_meaning")), "seq"), + "8": combo("Verb einsetzen", "sub_verb", str_list(A("ex8_pool")), + verb_items(A("ex8_data")), str_list(A("ex8_meaning")), + "verb"), + "9": { + "type": "choice", "title": "Wer? Wie? Wo?", "sub": "sub_wer", + "questions": choice_items(A("ex9_questions")), + "meanings": str_list(A("ex9_meaning")), + }, + "10": { + "type": "assembly", "title": "Wörter trennen", "sub": "sub_assembly", + "items": assembly_items(A("ex10_items")), + "meanings": str_list(A("ex10_meaning")), + }, + "11": { + "type": "assign", "title": "Grußformen", "sub": "sub_gruss", + "items": assign_items(A("ex11_items")), + "groups": str_list(A("ex11_groups")), + "meanings": str_list(A("ex11_meaning")), + }, + "12": combo("Was macht er/sie?", "sub_bild", str_list(A("ex12_pool")), + verb_clue_items(A("ex12_data")), + str_list(A("ex12_meaning")), "verbclue"), + "13": { + "type": "assign", "title": "Länder", "sub": "sub_land", + "items": assign_items(A("ex13_items")), + "groups": str_list(A("ex13_groups")), + "meanings": str_list(A("ex13_meaning")), + }, + }, + "vocab": { + "type": "vocab", "title": "Vokabeltraining", "sub": "sub_translate", + "sections": trans_sections(A("u1_trans_sections"), arrays), + }, + }, + { + "id": 1, + "title": "Aus aller Welt", + "page": "unit2", + "tag": "u2", + "sub": "unit2_sub", + "unlocked": True, + "names": [ + "Verben konjugieren", "aus oder in", "Fragewörter", "sprechen", + "Nationalitäten", "Woher?", "Länder schreiben", "Verben einsetzen", + "Freie Antwort", "Euro", "Wörter suchen", "Was ist richtig?", + "Ordne zu", "Lückentext", "Verbinde", "Zahlen", + "Steckbrief", "Berufe", "Nationalität", + ], + "branch": "u2vocab", + "exercises": { + "1": combo("Verben konjugieren", "sub_verben", str_list(A("g01_pool")), + [], [], "verb_sections", + sections=[ + {"title": "Peter Fritsch:", + "rows": verb_items(A("g01_peter")), + "meanings": str_list(A("g01_peter_mean"))}, + {"title": "Jana Nová und Pavol Korčák:", + "rows": verb_items(A("g01_jana")), + "meanings": str_list(A("g01_jana_mean"))}, + ], + transUpfront=True, revealGerman=True), + "2": { + "type": "choice", "title": "aus oder in", "sub": "sub_ausin", + "questions": choice_items(A("g02_rows")), + "meanings": str_list(A("g02_mean")), + }, + "3": { + "type": "choice", "title": "Fragewörter", "sub": "sub_wer", + "questions": choice_items(A("g03_rows")), + "meanings": str_list(A("g03_mean")), + }, + "4": combo("sprechen", "sub_sprich", str_list(A("g04_pool")), + verb_items(A("g04_rows")), str_list(A("g04_mean")), "verb"), + "5": { + "type": "typed", "title": "Nationalitäten", "sub": "sub_nation", + "rows": typed_items(A("g05_rows")), + "bank": as_str(A("g05_bank")[0]) if A("g05_bank") else + (as_str(arrays["g05_bank"][1][0]) if "g05_bank" in arrays + and arrays["g05_bank"][1] and isinstance(arrays["g05_bank"][1][0], str) + else None), + "answersNote": False, + }, + "6": { + "type": "typed", "title": "Woher?", "sub": "sub_woher", + "rows": typed_items(A("g06_rows")), + }, + "7": { + "type": "typed", "title": "Länder schreiben", "sub": "sub_laender", + "rows": typed_items(A("g07_rows")), + }, + "8": combo("Verben einsetzen", "sub_verb2", str_list(A("g08_pool")), + verb_items(A("g08_rows")), str_list(A("g08_mean")), "verb"), + "9": { + "type": "free", "title": "Freie Antwort", "sub": "sub_free", + "tip": "tip_ss", "questions": free_items(A("ex9_free_qs")), + }, + "10": combo("Euro", "sub_euro", str_list(A("g10_pool")), + verb_items(A("g10_rows")), str_list(A("g10_mean")), + "verb", preMeaning=True), + "11": { + "type": "typed", "title": "Wörter suchen", "sub": "sub_wortsuchen", + "rows": typed_items(A("g11_rows")), + }, + "12": { + "type": "choice", "title": "Was ist richtig?", "sub": "sub_verb2", + "questions": choice_items(A("g12_rows")), + "meanings": str_list(A("g12_mean")), + }, + "13": combo("Ordne zu", "sub_ordne", [], + ordne_items(A("g13_rows")), str_list(A("g13_mean")), + "ordne", + fwPool=str_list(A("g13_fw_pool")), + ansPool=str_list(A("g13_ans_pool"))), + "14": combo("Lückentext", "sub_luecke", str_list(A("g14_pool")), + verb_items(A("g14_rows")), str_list(A("g14_mean")), + "verb", preMeaning=True, gloss=str_list(A("g14_gloss"))), + "15": combo("Verbinde", "sub_verbinde", str_list(A("g15_pool")), + verb_items(A("g15_rows")), str_list(A("g15_mean")), + "verb", preMeaning=True, gloss=str_list(A("g15_gloss"))), + "16": combo("Zahlen", "sub_zahlpaar", str_list(A("g16_pool")), + verb_items(A("g16_rows")), str_list(A("g16_mean")), + "verb"), + "17": { + "type": "profile", "title": "Steckbrief", "sub": "sub_steckbrief", + "questions": profile_items(A("s01_rows")), + }, + "18": { + "type": "hangman", "title": "Berufe", "sub": "sub_berufe", + "words": str_list(A("hm_words")), + "tips": str_list(A("hm_tips")), + "letters": str_list(A("hm_letters")), + }, + "19": { + "type": "typed", "title": "Nationalität", "sub": "sub_bistdu", + "rows": typed_items(A("s03_rows")), + "answersNote": True, + }, + }, + "vocab": { + "type": "vocab", "title": "Vokabeltraining", "sub": "sub_translate", + "sections": trans_sections(A("u2_trans_sections"), arrays), + }, + }, + { + "id": 2, + "title": "Bei uns zu Hause", + "page": "unit3", + "tag": "u3", + "sub": "unit3_sub", + "unlocked": True, + "names": [ + "Familienpaare", "mein oder dein", "ein / kein", "Lückentext", + "Marcos Familie", "Sortieren", "Possessivtabelle", "Akkusativ", + "kein / nicht", "Sätze bauen", "Was siehst du?", "Wem gehört das?", + "Es gibt …", "Beschreiben", "Wochenende", + ], + "branch": "u3vocab", + "exercises": { + "1": combo("Familienpaare", "sub3_pair", str_list(A("ex1_u3_pool")), + fill_items(A("ex1_u3_rows")), [], "fill", + sample="Beispiel: der Vater ↔ die Mutter", + showGerman=False), + "2": { + "type": "ex2", "title": "mein oder dein", "sub": "sub3_poss", + "pool": str_list(A("ex2_u3_pool")), + "items": ex2_items(arrays), + }, + "3": combo("ein / kein", "sub3_haustier", str_list(A("ex3_u3_pool")), + fill_items(A("ex3_u3_rows")), [], "fill", + sample="Beispiel: Hast du eine Katze? – Nein, ich habe keine Katze. Ich habe einen Hund.", + showGerman=True), + "4": { + "type": "letters_gap", "title": "Lückentext", "sub": "sub3_buchst", + "rows": ex4row_items(A("ex4_u3_rows")), + }, + "5": { + "type": "typed", "title": "Marcos Familie", "sub": "sub3_marco", + "rows": typed_items(A("ex5_u3_rows")), + "bank": None, + }, + "6": { + "type": "assign", "title": "Sortieren", "sub": "sub3_sort", + "items": assign_items(A("ex6_u3_items")), + "groups": str_list(A("ex6_u3_groups")), + "meanings": str_list(A("ex6_u3_meaning")), + }, + "7": { + "type": "table", "title": "Possessivtabelle", "sub": "sub3_tabelle", + "persons": [ + "ich", "du", "er", "sie", "es", "wir", "ihr", "sie / Sie", + ], + "nouns": ["der Garten", "die Idee", "das Fest", "die Geschwister"], + "answers": [ + ["mein", "dein", "sein", "ihr", "sein", "unser", "euer", "ihr"], + ["meine", "deine", "seine", "ihre", "seine", "unsere", "eure", "ihre"], + ["mein", "dein", "sein", "ihr", "sein", "unser", "euer", "ihr"], + ["meine", "deine", "seine", "ihre", "seine", "unsere", "eure", "ihre"], + ], + }, + "8": combo("Akkusativ", "sub3_akk", str_list(A("ex8_u3_pool")), + fill_items(A("ex8_u3_rows")), str_list(A("ex8_u3_gloss")), + "fill", showGerman=True), + "9": combo("kein / nicht", "sub3_nicht", str_list(A("ex9_u3_pool")), + fill_items(A("ex9_u3_rows")), [], "fill", showGerman=True), + "10": { + "type": "assembly", "title": "Sätze bauen", "sub": "sub3_satz", + "items": assembly_items(A("ex10_u3_items")), + "meanings": str_list(A("ex10_u3_meaning")), + }, + "11": { + "type": "kw", "title": "Was siehst du?", "sub": "sub3_sehen", + "sample": "Beispiel: Ich sehe eine Burg. / Ich sehe ein Schloss.", + "rows": kw_items(A("ex11_u3_qs")), + }, + "12": combo("Wem gehört das?", "sub3_wem", str_list(A("ex12_u3_pool")), + fill_items(A("ex12_u3_rows")), [], "fill", showGerman=True), + "13": combo("Es gibt …", "sub3_gibt", str_list(A("ex13_u3_pool")), + fill_items(A("ex13_u3_rows")), [], "fill", showGerman=True), + "14": { + "type": "kw", "title": "Beschreiben", "sub": "sub3_saetze", + "sample": "Beispiel: Ich sehe einen Jungen. Er liest ein Buch.", + "rows": kw_items(A("ex14_u3_qs")), + }, + "15": { + "type": "letters", "title": "Wochenende", "sub": "sub3_wochen", + "rows": let_items(A("ex15_u3_rows")), + }, + }, + "vocab": { + "type": "vocab", "title": "Vokabeltraining", "sub": "sub_translate", + "sections": trans_sections(A("u3_trans_sections"), arrays), + }, + }, + ] + + locked_german = [ + "Schule und Freizeit", "Guten Appetit!", "Mein Tagesablauf", + "Meine Freunde", "Wir treffen uns in Salzburg", + "Mein Haus ist meine Burg", "Urlaub in Österreich", + ] + for i, title in enumerate(locked_german, start=3): + german_units.append({ + "id": i, "title": title, "page": None, "tag": None, + "sub": None, "unlocked": False, "names": [], + "exercises": {}, + }) + + # g05_bank is a single string, not an array of strings + if "g05_bank" in arrays: + raw = arrays["g05_bank"][1] + bank = None + if isinstance(raw, list) and raw and isinstance(raw[0], str) and len(raw) == 1: + bank = raw[0] + elif isinstance(raw, str): + bank = raw + if bank: + german_units[1]["exercises"]["5"]["bank"] = bank + + if "ex5_u3_bank" in arrays: + raw = arrays["ex5_u3_bank"][1] + if isinstance(raw, list) and raw and isinstance(raw[0], str): + german_units[2]["exercises"]["5"]["bank"] = raw[0] if len(raw) == 1 else " ".join( + x for x in raw if isinstance(x, str) + ) + + net_lessons = [] + for i in range(1, 28): + slides = slides_items(A(f"build_net_unit{i}_page.slides")) + qs = choice_items(A(f"net{i}_qs")) if i > 1 else [] + net_lessons.append({ + "id": i, + "titleKey": f"net_unit{i}", + "subKey": f"net_unit{i}_sub", + "exTitleKey": f"net_ex{i}_title" if i > 1 else "net_ex_title", + "quizHeadKey": f"net_quiz{i}_head" if i > 1 else None, + "slides": slides, + "quiz": qs, + }) + + hw_lessons = [] + for i in range(1, 4): + hw_lessons.append({ + "id": i, + "titleKey": f"hw_unit{i}", + "subKey": f"hw_unit{i}_sub", + "exTitleKey": f"hw_ex{i}_title", + "quizHeadKey": f"hw_quiz{i}_head", + "slides": slides_items(A(f"build_hw_unit{i}_page.slides")), + "quiz": choice_items(A(f"build_hw_unit{i}_exercise_page.qs")), + }) + + mluv_meta = [ + (1, "typed", "Pravopis – i/y po obojetných souhláskách", + "Doplňte i/í nebo y/ý.", None, "e1_items"), + (2, "choice", "Pravopis – velká/malá písmena", + "Vyberte správnou variantu.", None, "e2_qs"), + (3, "typed", "Slovní druhy", + "Určete slovní druh podtržených slov.", + "Do pole napište název slovního druhu.", "e3_items"), + (4, "typed", "Mluvnické kategorie – podstatná jména", + "Určete rod, číslo a pád podtržených slov.", + "Vyplňte tři pole: rod, číslo a pád.", "e4_items"), + (5, "typed", "Mluvnické kategorie – slovesa", + "Určete osobu, číslo, čas a způsob u těchto sloves.", + "U rozkazovacího a podmiňovacího způsobu napište do pole čas „neurčitý“.", + "e5_items"), + (6, "reveal", "Slovotvorba", "Rozeberte slova na morfémy.", + "Určete předponu, kořen, příponu a koncovku.", "e6_items"), + (7, "typed", "Větné členy", + "Určete větný člen podtržených výrazů.", None, "e7_items"), + (8, "typed", "Druhy vedlejších vět", + "Určete druh vedlejší věty.", None, "e8_items"), + (9, "typed", "Synonyma", "Ke každému slovu napište synonymum.", None, "e9_items"), + (10, "typed", "Antonyma", "Napište opak.", None, "e10_items"), + (11, "typed", "Pravopis – s/z na začátku slova", + "Vyberte správnou předponu s-/z-.", None, "e11_items"), + (12, "reveal", "Skladba – najděte chybu", + "Každá věta obsahuje jednu chybu. Najděte ji a opravte.", None, "e12_items"), + (13, "typed", "Tvarosloví – správný tvar", + "Dejte slovo do správného tvaru.", None, "e13_items"), + (14, "choice", "Výběr ze čtyř možností", + "Vyberte správnou odpověď.", None, "e14_qs"), + (15, "reveal", "Přímá a nepřímá řeč", + "Přepište přímou řeč na nepřímou.", None, "e15_items"), + (16, "reveal", "Slovní zásoba – rozdíly", + "Vysvětlete rozdíl mezi těmito dvojicemi slov.", None, "e16_items"), + (17, "reveal", "Interpunkce", + "Doplňte čárky tam, kde patří.", None, "e17_items"), + (18, "typed", "Obrazná pojmenování", + "Určete, o jaký druh obrazného pojmenování jde.", None, "e18_items"), + (19, "typed", "Stylistika – slohové útvary", + "Přiřaďte ukázku ke správnému slohovému útvaru.", None, "e19_items"), + (20, "choice", "Souhrnné opakování", + "Vyberte správnou odpověď.", None, "e20_qs"), + ] + mluv_names = [ + "i/y", "Velká písmena", "Slovní druhy", "Podst. jména", "Slovesa", + "Slovotvorba", "Větné členy", "Vedlejší věty", "Synonyma", "Antonyma", + "s/z", "Najdi chybu", "Tvary", "Test A–D", "Přímá řeč", + "Rozdíly", "Interpunkce", "Obrazná pojmen.", "Slohové útvary", + "Opakování", + ] + mluvnice = [] + for n, kind, title, sub, note, key in mluv_meta: + item = {"id": n, "name": mluv_names[n - 1], "type": kind, + "title": title, "sub": sub, "note": note} + if kind == "typed": + item["items"] = mluv_items(A(key)) + elif kind == "reveal": + item["items"] = reveal_items(A(key)) + else: + item["questions"] = choice_items(A(key)) + expl_key = key.replace("_qs", "_expl") + if expl_key in arrays: + item["expls"] = str_list(A(expl_key)) + mluvnice.append(item) + + def note_lines(fn, name): + return str_list(A(f"{fn}.{name}")) + + books = [ + { + "id": "1984", + "title": "George Orwell – 1984", + "subKey": "cetba1984_sub", + "page": "cetba1984", + "quizTitle": "lit_quiz_title", + "quizSub": "lit_quiz_sub", + "plotTitle": "lit_plot_title", + "plotSub": "lit_plot_sub", + "notes": [ + {"title": "O knize", "icon": "book", + "lines": note_lines("build_cetba1984_page", "about")}, + {"title": "Svět a strana", "icon": "globe", + "lines": note_lines("build_cetba1984_page", "world")}, + {"title": "Postavy", "icon": "people", + "lines": note_lines("build_cetba1984_page", "people")}, + {"title": "Klíčové pojmy", "icon": "bulb", + "lines": note_lines("build_cetba1984_page", "terms")}, + ], + "quiz": lit_items(A("lit_qs")), + "plot": assembly_items(A("lit_plot_items")), + "plotMeaning": str_list(A("lit_plot_meaning")), + }, + { + "id": "fuks", + "title": "Ladislav Fuks – Spalovač mrtvol", + "subKey": "cetbaFuks_sub", + "page": "cetbaFuks", + "quizTitle": "lit_fuks_quiz_title", + "quizSub": "lit_fuks_quiz_sub", + "plotTitle": "lit_fuks_plot_title", + "plotSub": "lit_fuks_plot_sub", + "notes": [ + {"title": "O knize", "icon": "book", + "lines": note_lines("build_cetba_fuks_page", "about")}, + {"title": "Doba a svět", "icon": "globe", + "lines": note_lines("build_cetba_fuks_page", "world")}, + {"title": "Postavy", "icon": "people", + "lines": note_lines("build_cetba_fuks_page", "people")}, + {"title": "Klíčové motivy", "icon": "bulb", + "lines": note_lines("build_cetba_fuks_page", "terms")}, + ], + "quiz": lit_items(A("fuks_qs")), + "plot": assembly_items(A("fuks_plot_items")), + "plotMeaning": str_list(A("fuks_plot_meaning")), + }, + ] + + subjects = [ + {"key": "Deutsch", "open": True, "target": "roadmap", "icon": "de"}, + {"key": "Správa počítačových sítí", "open": True, "target": "netyears", "icon": "wifi"}, + {"key": "Technické vybavení", "open": True, "target": "hwmap", "icon": "chip"}, + {"key": "Český jazyk a literatura", "open": True, "target": "czechmap", "icon": "cz"}, + {"key": "Občanská nauka", "open": False, "target": None, "icon": "lock"}, + {"key": "English", "open": False, "target": None, "icon": "lock"}, + {"key": "Matematika", "open": False, "target": None, "icon": "lock"}, + {"key": "Fyzika", "open": False, "target": None, "icon": "lock"}, + {"key": "Základy Přírodopisných věd", "open": False, "target": None, "icon": "lock"}, + {"key": "Technická grafika", "open": False, "target": None, "icon": "lock"}, + {"key": "Prezentační grafika", "open": False, "target": None, "icon": "lock"}, + {"key": "Programování", "open": False, "target": None, "icon": "lock"}, + {"key": "Programové vybavení", "open": False, "target": None, "icon": "lock"}, + ] + + payload = { + "i18n": i18n, + "subjects": subjects, + "german": german_units, + "net": { + "lessons": net_lessons, + "tasks": net_tasks(A("net_tasks")), + "answers": net_ans(A("net_ans")), + }, + "hw": hw_lessons, + "mluvnice": mluvnice, + "books": books, + } + + # Fix single-string banks stored as char* "arrays" + for key in ("g05_bank", "ex5_u3_bank"): + if key not in arrays: + continue + raw = arrays[key][1] + if isinstance(raw, list) and len(raw) == 1 and isinstance(raw[0], str) and " " in raw[0]: + if key == "g05_bank": + german_units[1]["exercises"]["5"]["bank"] = raw[0] + else: + german_units[2]["exercises"]["5"]["bank"] = raw[0] + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + def count(path, pred): + node = payload + for p in path: + node = node[p] + return sum(1 for x in node if pred(x)) + + print(f"wrote {OUT}") + print(f" i18n keys: {len(i18n)}") + print(f" german unlocked exercises: " + f"{sum(len(u.get('exercises', {})) for u in german_units if u['unlocked'])}") + print(f" net lessons with slides: {sum(1 for L in net_lessons if L['slides'])}") + print(f" net quizzes: {sum(1 for L in net_lessons if L['quiz'])}") + print(f" hw lessons: {sum(1 for L in hw_lessons if L['slides'])}") + print(f" mluvnice: {len(mluvnice)}") + print(f" books quiz items: {[len(b['quiz']) for b in books]}") + missing = [] + for u in german_units: + if not u["unlocked"]: + continue + for k, ex in u["exercises"].items(): + t = ex["type"] + if t == "choice" and not ex.get("questions"): + missing.append(f"{u['tag']}e{k} choice") + if t == "typed" and not ex.get("rows"): + missing.append(f"{u['tag']}e{k} typed") + if t == "assembly" and not ex.get("items"): + missing.append(f"{u['tag']}e{k} assembly") + if t == "vocab" and not ex.get("sections"): + missing.append(f"{u['tag']} vocab") + if t == "fill" and not ex.get("rows"): + missing.append(f"{u['tag']}e{k} fill") + if t == "hangman" and not ex.get("words"): + missing.append(f"{u['tag']}e{k} hangman") + if u.get("vocab") and not u["vocab"].get("sections"): + missing.append(f"{u['tag']} vocab sections") + for L in net_lessons: + if not L["slides"]: + missing.append(f"net{L['id']} slides") + if L["id"] > 1 and not L["quiz"]: + missing.append(f"net{L['id']} quiz") + for L in hw_lessons: + if not L["slides"]: + missing.append(f"hw{L['id']} slides") + if not L["quiz"]: + missing.append(f"hw{L['id']} quiz") + for m in mluvnice: + if m["type"] in ("typed", "reveal") and not m.get("items"): + missing.append(f"mluv{m['id']}") + if m["type"] == "choice" and not m.get("questions"): + missing.append(f"mluv{m['id']}") + if missing: + print("MISSING:") + for m in missing: + print(" ", m) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/i18n.c b/src/i18n.c index 0ee089a..3a69e7c 100644 --- a/src/i18n.c +++ b/src/i18n.c @@ -56,6 +56,11 @@ const TrEntry tr_ui[] = { "napsané v C studenty ze SSŠVT.", "Exam practice in one window. Czech and English, written in C " "by students from SSŠVT."}, + {"welcome_body_android", + "Procvičování k maturitě v telefonu. Česky i anglicky, " + "stejný obsah jako na počítači.", + "Exam practice on your phone. Czech and English, " + "the same content as on the computer."}, {"welcome_feat1_title", "Efektivní procvičování", "Focused practice"}, {"welcome_feat1_body", "Cvičení, nápovědy a vokabeltraining. Aplikace si pamatuje, kde jste skončili.", @@ -485,6 +490,9 @@ const TrEntry tr_ui[] = { {"net_slide_prev", "Zpět", "Back"}, {"net_slide_start", "Přejít na cvičení", "Go to the exercise"}, {"net_solution", "Ukázat řešení", "Show solution"}, + {"net_infeasible", + "Toto zadání se do sítě /24 nevejde – vyzkoušej „Ukázat řešení“ a přečti si proč.", + "This assignment does not fit into a /24 network — tap “Show solution” to see why."}, {"hw_sub", "Vyberte lekci na cestě a začněte procvičovat.", "Pick a unit on the path and start practicing."}, {"hw_unit1", "Architektura počítače", "Computer architecture"},