Build all apps from Markdown content; Compose Desktop replaces GTK on Linux and Windows

- content/ + scripts/build-content.py produce content.json for every app
- stable lesson IDs with one-time progress migration (Swift, Kotlin)
- shared answer-check tests (tests/answers.json) for Kotlin and Swift
- android/desktop: Compose app with updater, speech and GTK progress import
- CI packages AppImage and Windows zip with ProGuard, runs the tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-09 20:40:37 +02:00
commit c3bc32113b
964 changed files with 57285 additions and 1156 deletions

View file

@ -66,6 +66,9 @@ android {
compose = true
buildConfig = true
}
// UI, data and navigation shared with the desktop app (../desktop).
sourceSets.getByName("main").kotlin.directories.add("../shared/src")
}
kotlin {
@ -88,13 +91,14 @@ dependencies {
debugImplementation("androidx.compose.ui:ui-tooling")
}
val extractContent = tasks.register<Exec>("extractContent") {
val buildContent = tasks.register<Exec>("buildContent") {
workingDir = rootProject.projectDir.parentFile
commandLine("python3", "scripts/extract-android-content.py")
inputs.dir(rootProject.projectDir.parentFile.resolve("src"))
commandLine("python3", "scripts/build-content.py")
inputs.dir(rootProject.projectDir.parentFile.resolve("content"))
inputs.file(rootProject.projectDir.parentFile.resolve("scripts/build-content.py"))
outputs.file(file("src/main/assets/content.json"))
}
tasks.named("preBuild").configure {
dependsOn(extractContent)
dependsOn(buildContent)
}

View file

@ -0,0 +1,88 @@
package org.maturita.maturita
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import org.maturita.maturita.platform.Platform
import org.maturita.maturita.platform.Prefs
import org.maturita.maturita.platform.Speaker
import org.maturita.maturita.update.Updater
import java.util.Locale
class AndroidPlatform(private val app: Application) : Platform {
override val commit: String = BuildConfig.COMMIT
override val versionName: String = BuildConfig.VERSION_NAME
override val welcomeKey = "welcome_body_android"
override val prefs: Prefs = AndroidPrefs(app.getSharedPreferences("maturita", Context.MODE_PRIVATE))
override val updater = Updater(app)
override fun speaker(): Speaker = AndroidSpeaker(app)
}
private class AndroidPrefs(private val sp: SharedPreferences) : Prefs {
override fun getBoolean(key: String, fallback: Boolean) = sp.getBoolean(key, fallback)
override fun getInt(key: String, fallback: Int) = sp.getInt(key, fallback)
override fun getString(key: String, fallback: String?): String? = sp.getString(key, fallback)
override fun edit(): Prefs.Editor = Editor(sp.edit())
private class Editor(private val e: SharedPreferences.Editor) : Prefs.Editor {
override fun putBoolean(key: String, value: Boolean) = apply { e.putBoolean(key, value) }
override fun putInt(key: String, value: Int) = apply { e.putInt(key, value) }
override fun putString(key: String, value: String) = apply { e.putString(key, value) }
override fun apply() = e.apply()
}
}
private class AndroidSpeaker(context: Context) : Speaker {
private val main = Handler(Looper.getMainLooper())
private var ready = false
private val tts: TextToSpeech = TextToSpeech(context) { status ->
ready = status == TextToSpeech.SUCCESS
}
override fun speak(
text: String,
locale: Locale,
rate: Float,
volume: Float,
onRange: (Int) -> Unit,
onEnd: (Boolean) -> Unit,
) {
if (!ready) {
main.post { onEnd(false) }
return
}
val id = "en-${System.nanoTime()}"
tts.language = locale
tts.setSpeechRate(rate)
tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onRangeStart(utteranceId: String?, rangeStart: Int, rangeEnd: Int, frame: Int) {
if (utteranceId == id) main.post { onRange(rangeStart) }
}
override fun onDone(utteranceId: String?) {
if (utteranceId == id) main.post { onEnd(true) }
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
if (utteranceId == id) main.post { onEnd(false) }
}
})
val params = Bundle().apply { putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume) }
tts.speak(text, TextToSpeech.QUEUE_FLUSH, params, id)
}
override fun stop() {
tts.stop()
}
override fun close() {
tts.stop()
tts.shutdown()
}
}

View file

@ -1,15 +1,7 @@
package org.maturita.maturita
import android.app.Application
import android.os.Bundle
import java.util.Locale
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import android.util.TypedValue
import android.view.ViewGroup
import android.widget.Button
@ -21,60 +13,19 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.data.Palette
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.HomeScreen
import org.maturita.maturita.ui.HwQuizScreen
import org.maturita.maturita.ui.HwYearsScreen
import org.maturita.maturita.ui.LessonListScreen
import org.maturita.maturita.ui.FyzQuizScreen
import org.maturita.maturita.ui.MathPracticeScreen
import org.maturita.maturita.ui.MatYearsScreen
import org.maturita.maturita.ui.FyzYearsScreen
import org.maturita.maturita.ui.LitYearsScreen
import org.maturita.maturita.ui.LessonRow
import org.maturita.maturita.ui.LitQuizScreen
import org.maturita.maturita.ui.SciMapScreen
import org.maturita.maturita.ui.SciQuizScreen
import org.maturita.maturita.ui.MluvExercise
import org.maturita.maturita.ui.NetQuizScreen
import org.maturita.maturita.ui.NetYearsScreen
import org.maturita.maturita.ui.On2QuizScreen
import org.maturita.maturita.ui.On3QuizScreen
import org.maturita.maturita.ui.On4QuizScreen
import org.maturita.maturita.ui.OnQuizScreen
import org.maturita.maturita.ui.OnYearsScreen
import org.maturita.maturita.ui.EnYearsScreen
import org.maturita.maturita.ui.ChoiceLessonScreen
import org.maturita.maturita.ui.DeHomeScreen
import org.maturita.maturita.ui.ItYearScreen
import org.maturita.maturita.ui.EngExScreen
import org.maturita.maturita.ui.PlotScreen
import org.maturita.maturita.ui.RoadmapScreen
import org.maturita.maturita.ui.SearchScreen
import org.maturita.maturita.ui.SettingsScreen
import org.maturita.maturita.ui.SlidesScreen
import org.maturita.maturita.ui.StatsScreen
import org.maturita.maturita.ui.UnitMapScreen
import org.maturita.maturita.ui.VocabExercise
import org.maturita.maturita.ui.appTypography
import androidx.lifecycle.AndroidViewModel
import org.maturita.maturita.data.Content
/** Keeps the shared app state alive across rotation and other config changes. */
class AppHolder(app: Application) : AndroidViewModel(app) {
val vm = AppViewModel(
Content.parse(runCatching { app.assets.open("content.json").bufferedReader().use { it.readText() } }.getOrNull()),
AndroidPlatform(app),
)
}
class MainActivity : ComponentActivity() {
private val vm: AppViewModel by viewModels()
private val holder: AppHolder by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -96,7 +47,11 @@ class MainActivity : ComponentActivity() {
return
}
enableEdgeToEdge()
setContent { MaturitaApp(vm) }
val vm = holder.vm
setContent {
BackHandler(vm.canGoBack || vm.searchOpen) { vm.handleBack() }
MaturitaApp(vm)
}
}
private fun crashView(dump: String, retry: () -> Unit): LinearLayout {
@ -135,512 +90,3 @@ class MainActivity : ComponentActivity() {
}
}
/** Material 3 colour roles from the shared theme palette. */
private fun schemeFor(p: Palette, dark: Boolean) = if (dark) {
darkColorScheme(
primary = p.tint,
onPrimary = p.onTint,
primaryContainer = p.accent,
onPrimaryContainer = p.onAccent,
secondary = p.accent2,
onSecondary = p.onAccent,
secondaryContainer = p.surface1,
onSecondaryContainer = p.text,
tertiary = p.warning,
onTertiary = p.crust,
background = p.base,
onBackground = p.text,
surface = p.base,
onSurface = p.text,
surfaceVariant = p.surface0,
onSurfaceVariant = p.subtext,
surfaceContainerLowest = p.crust,
surfaceContainerLow = p.mantle,
surfaceContainer = p.mantle,
surfaceContainerHigh = p.surface0,
surfaceContainerHighest = p.surface1,
outline = p.overlay,
outlineVariant = p.surface1,
error = p.error,
onError = Color.White,
errorContainer = p.error.copy(alpha = 0.22f),
onErrorContainer = p.text,
)
} else {
lightColorScheme(
primary = p.tint,
onPrimary = p.onTint,
primaryContainer = p.accent,
onPrimaryContainer = p.onAccent,
secondary = p.accent2,
onSecondary = Color.White,
secondaryContainer = p.surface1,
onSecondaryContainer = p.text,
tertiary = p.warning,
onTertiary = Color.White,
background = p.base,
onBackground = p.text,
surface = p.base,
onSurface = p.text,
surfaceVariant = p.surface1,
onSurfaceVariant = p.subtext,
surfaceContainerLowest = p.mantle,
surfaceContainerLow = p.mantle,
surfaceContainer = p.mantle,
surfaceContainerHigh = p.surface1,
surfaceContainerHighest = p.surface2,
outline = p.overlay,
outlineVariant = p.surface2,
error = p.error,
onError = Color.White,
errorContainer = p.error.copy(alpha = 0.14f),
onErrorContainer = p.text,
)
}
@Composable
fun MaturitaApp(vm: AppViewModel) {
MaterialTheme(colorScheme = schemeFor(vm.palette, vm.mode == ColorMode.Dark), typography = appTypography()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MaturitaAppBody(vm)
}
}
}
@Composable
private fun MaturitaAppBody(vm: AppViewModel) {
LaunchedEffect(Unit) { vm.checkUpdate(false) }
BackHandler(vm.canGoBack || vm.searchOpen) {
if (vm.searchOpen) vm.searchOpen = false else vm.back()
}
Box(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = vm.route,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
val tabs = targetState.isRoot && initialState.isRoot
if (tabs) {
fadeIn(tween(180)) togetherWith fadeOut(tween(140))
} else if (vm.navigatingForward) {
(fadeIn(tween(220)) + slideInHorizontally(tween(220)) { it / 5 })
.togetherWith(fadeOut(tween(150)) + slideOutHorizontally(tween(150)) { -it / 8 })
} else {
(fadeIn(tween(220)) + slideInHorizontally(tween(220)) { -it / 8 })
.togetherWith(fadeOut(tween(150)) + slideOutHorizontally(tween(150)) { it / 5 })
}
},
label = "screen",
) { route ->
when (val r = route) {
Route.Welcome, Route.Subjects -> HomeScreen(vm)
Route.Stats -> StatsScreen(vm)
Route.Settings -> SettingsScreen(vm)
Route.DeHome -> DeHomeScreen(vm)
is Route.DeMap -> LessonListScreen(
vm, vm.tr("de_year${r.year}"), vm.tr("de_y${r.year}_sub"),
vm.content.deYear(r.year).map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.deDone(r.year, id), Route.DeLesson(r.year, id))
},
)
is Route.DeLesson -> {
val l = vm.content.deLesson(r.year, r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.DeEx(r.year, r.id))
}
}
is Route.DeEx -> EngExScreen(
vm, r.year, r.id, Locale.GERMANY, vm.content.deLesson(r.year, r.id),
) { vm.markDe(r.year, r.id) }
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 -> LessonListScreen(
vm, vm.tr("net_year1"), vm.tr("net_sub"),
vm.content.netLessons.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.netDone(id), Route.NetLesson(id))
},
)
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.replace(Route.NetEx(r.id))
}
}
is Route.NetEx -> NetQuizScreen(vm, r.id)
Route.Net2Map -> ItYearScreen(vm, "net2", "net_year2", "net2_sub") { Route.Net2Lesson(it) }
is Route.Net2Lesson -> {
val l = vm.content.itLesson("net2", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Net2Ex(r.id))
}
}
is Route.Net2Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("net2", r.id)) { vm.markCourse("net2", r.id) }
Route.Net3Map -> ItYearScreen(vm, "net3", "net_year3", "net3_sub") { Route.Net3Lesson(it) }
is Route.Net3Lesson -> {
val l = vm.content.itLesson("net3", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Net3Ex(r.id))
}
}
is Route.Net3Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("net3", r.id)) { vm.markCourse("net3", r.id) }
Route.Net4Map -> ItYearScreen(vm, "net4", "net_year4", "net4_sub") { Route.Net4Lesson(it) }
is Route.Net4Lesson -> {
val l = vm.content.itLesson("net4", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Net4Ex(r.id))
}
}
is Route.Net4Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("net4", r.id)) { vm.markCourse("net4", r.id) }
Route.HwYears -> HwYearsScreen(vm)
Route.HwMap -> LessonListScreen(
vm, vm.tr("hw_year1"), vm.tr("hw_sub"),
vm.content.hw.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.hwDone(id), Route.HwLesson(id))
},
)
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.replace(Route.HwEx(r.id))
}
}
is Route.HwEx -> HwQuizScreen(vm, r.id)
Route.Hw2Map -> ItYearScreen(vm, "hw2", "hw_year2", "hw2_sub") { Route.Hw2Lesson(it) }
is Route.Hw2Lesson -> {
val l = vm.content.itLesson("hw2", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Hw2Ex(r.id))
}
}
is Route.Hw2Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("hw2", r.id)) { vm.markCourse("hw2", r.id) }
Route.Hw3Map -> ItYearScreen(vm, "hw3", "hw_year3", "hw3_sub") { Route.Hw3Lesson(it) }
is Route.Hw3Lesson -> {
val l = vm.content.itLesson("hw3", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Hw3Ex(r.id))
}
}
is Route.Hw3Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("hw3", r.id)) { vm.markCourse("hw3", r.id) }
Route.Hw4Map -> ItYearScreen(vm, "hw4", "hw_year4", "hw4_sub") { Route.Hw4Lesson(it) }
is Route.Hw4Lesson -> {
val l = vm.content.itLesson("hw4", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Hw4Ex(r.id))
}
}
is Route.Hw4Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("hw4", r.id)) { vm.markCourse("hw4", r.id) }
Route.EnYears -> EnYearsScreen(vm)
is Route.EnMap -> LessonListScreen(
vm, vm.tr("en_year${r.year}"), vm.tr("en_y${r.year}_sub"),
vm.content.enYear(r.year).map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.enDone(r.year, id), Route.EnLesson(r.year, id))
},
)
is Route.EnLesson -> {
val l = vm.content.enLesson(r.year, r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.EnEx(r.year, r.id))
}
}
is Route.EnEx -> EngExScreen(vm, r.year, r.id)
Route.OnYears -> OnYearsScreen(vm)
Route.OnMap -> LessonListScreen(
vm, vm.tr("on_year1"), vm.tr("on_sub"),
vm.content.on.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.onDone(id), Route.OnLesson(id))
},
)
is Route.OnLesson -> {
val l = vm.content.onLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.OnEx(r.id))
}
}
is Route.OnEx -> OnQuizScreen(vm, r.id)
Route.On2Map -> LessonListScreen(
vm, vm.tr("on_year2"), vm.tr("on2_sub"),
vm.content.on2.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.on2Done(id), Route.On2Lesson(id))
},
)
is Route.On2Lesson -> {
val l = vm.content.on2Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.On2Ex(r.id))
}
}
is Route.On2Ex -> On2QuizScreen(vm, r.id)
Route.On3Map -> LessonListScreen(
vm, vm.tr("on_year3"), vm.tr("on3_sub"),
vm.content.on3.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.on3Done(id), Route.On3Lesson(id))
},
)
is Route.On3Lesson -> {
val l = vm.content.on3Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.On3Ex(r.id))
}
}
is Route.On3Ex -> On3QuizScreen(vm, r.id)
Route.On4Map -> LessonListScreen(
vm, vm.tr("on_year4"), vm.tr("on4_sub"),
vm.content.on4.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.on4Done(id), Route.On4Lesson(id))
},
)
is Route.On4Lesson -> {
val l = vm.content.on4Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.On4Ex(r.id))
}
}
is Route.On4Ex -> On4QuizScreen(vm, r.id)
Route.LitYears -> LitYearsScreen(vm)
Route.LitMap -> LessonListScreen(
vm, vm.tr("lit_year1"), vm.tr("lit_sub"),
vm.content.lit.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.litDone(id), Route.LitLesson(id))
},
)
is Route.LitLesson -> {
val l = vm.content.litLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.LitEx(r.id))
}
}
is Route.LitEx -> LitQuizScreen(vm, 1, r.id)
Route.Lit2Map -> LessonListScreen(
vm, vm.tr("lit_year2"), vm.tr("lit2_sub"),
vm.content.lit2.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.lit2Done(id), Route.Lit2Lesson(id))
},
)
is Route.Lit2Lesson -> {
val l = vm.content.lit2Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Lit2Ex(r.id))
}
}
is Route.Lit2Ex -> LitQuizScreen(vm, 2, r.id)
Route.Lit3Map -> LessonListScreen(
vm, vm.tr("lit_year3"), vm.tr("lit3_sub"),
vm.content.lit3.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.lit3Done(id), Route.Lit3Lesson(id))
},
)
is Route.Lit3Lesson -> {
val l = vm.content.lit3Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Lit3Ex(r.id))
}
}
is Route.Lit3Ex -> LitQuizScreen(vm, 3, r.id)
Route.Lit4Map -> LessonListScreen(
vm, vm.tr("lit_year4"), vm.tr("lit4_sub"),
vm.content.lit4.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.lit4Done(id), Route.Lit4Lesson(id))
},
)
is Route.Lit4Lesson -> {
val l = vm.content.lit4Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Lit4Ex(r.id))
}
}
is Route.Lit4Ex -> LitQuizScreen(vm, 4, r.id)
Route.MatYears -> MatYearsScreen(vm)
Route.Mat0Map -> LessonListScreen(
vm, vm.tr("mat0_year"), vm.tr("mat0_sub"),
vm.content.mat0.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat0Done(id), Route.Mat0Lesson(id))
},
)
is Route.Mat0Lesson -> {
val l = vm.content.mat0Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Mat0Ex(r.id))
}
}
is Route.Mat0Ex -> MathPracticeScreen(vm, 0, r.id)
Route.MatMap -> LessonListScreen(
vm, vm.tr("mat_year1"), vm.tr("mat_sub"),
vm.content.mat.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.matDone(id), Route.MatLesson(id))
},
)
is Route.MatLesson -> {
val l = vm.content.matLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.MatEx(r.id))
}
}
is Route.MatEx -> MathPracticeScreen(vm, 1, r.id)
Route.Mat2Map -> LessonListScreen(
vm, vm.tr("mat_year2"), vm.tr("mat2_sub"),
vm.content.mat2.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat2Done(id), Route.Mat2Lesson(id))
},
)
is Route.Mat2Lesson -> {
val l = vm.content.mat2Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Mat2Ex(r.id))
}
}
is Route.Mat2Ex -> MathPracticeScreen(vm, 2, r.id)
Route.Mat3Map -> LessonListScreen(
vm, vm.tr("mat_year3"), vm.tr("mat3_sub"),
vm.content.mat3.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat3Done(id), Route.Mat3Lesson(id))
},
)
is Route.Mat3Lesson -> {
val l = vm.content.mat3Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Mat3Ex(r.id))
}
}
is Route.Mat3Ex -> MathPracticeScreen(vm, 3, r.id)
Route.Mat4Map -> LessonListScreen(
vm, vm.tr("mat_year4"), vm.tr("mat4_sub"),
vm.content.mat4.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat4Done(id), Route.Mat4Lesson(id))
},
)
is Route.Mat4Lesson -> {
val l = vm.content.mat4Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Mat4Ex(r.id))
}
}
is Route.Mat4Ex -> MathPracticeScreen(vm, 4, r.id)
Route.FyzYears -> FyzYearsScreen(vm)
Route.FyzMap -> LessonListScreen(
vm, vm.tr("fyz_year1"), vm.tr("fyz_sub"),
vm.content.fyz.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyzDone(id), Route.FyzLesson(id))
},
)
is Route.FyzLesson -> {
val l = vm.content.fyzLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.FyzEx(r.id))
}
}
is Route.FyzEx -> FyzQuizScreen(vm, 1, r.id)
Route.Fyz2Map -> LessonListScreen(
vm, vm.tr("fyz_year2"), vm.tr("fyz2_sub"),
vm.content.fyz2.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyz2Done(id), Route.Fyz2Lesson(id))
},
)
is Route.Fyz2Lesson -> {
val l = vm.content.fyz2Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Fyz2Ex(r.id))
}
}
is Route.Fyz2Ex -> FyzQuizScreen(vm, 2, r.id)
Route.Fyz3Map -> LessonListScreen(
vm, vm.tr("fyz_year3"), vm.tr("fyz3_sub"),
vm.content.fyz3.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyz3Done(id), Route.Fyz3Lesson(id))
},
)
is Route.Fyz3Lesson -> {
val l = vm.content.fyz3Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Fyz3Ex(r.id))
}
}
is Route.Fyz3Ex -> FyzQuizScreen(vm, 3, r.id)
Route.Fyz4Map -> LessonListScreen(
vm, vm.tr("fyz_year4"), vm.tr("fyz4_sub"),
vm.content.fyz4.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyz4Done(id), Route.Fyz4Lesson(id))
},
)
is Route.Fyz4Lesson -> {
val l = vm.content.fyz4Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Fyz4Ex(r.id))
}
}
is Route.Fyz4Ex -> FyzQuizScreen(vm, 4, r.id)
Route.SciMap -> SciMapScreen(vm)
Route.ChemMap -> LessonListScreen(
vm, vm.tr("Chemie"), vm.tr("chem_sub"),
vm.content.chem.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.chemDone(id), Route.ChemLesson(id))
},
)
is Route.ChemLesson -> {
val l = vm.content.chemLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.ChemEx(r.id))
}
}
is Route.ChemEx -> SciQuizScreen(vm, "chem", r.id)
Route.BioMap -> LessonListScreen(
vm, vm.tr("Biologie"), vm.tr("bio_sub"),
vm.content.bio.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.bioDone(id), Route.BioLesson(id))
},
)
is Route.BioLesson -> {
val l = vm.content.bioLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.BioEx(r.id))
}
}
is Route.BioEx -> SciQuizScreen(vm, "bio", r.id)
Route.CzechMap -> CzechMapScreen(vm)
Route.Mluvnice -> LessonListScreen(
vm, vm.tr("Mluvnice"), vm.tr("mluv_sub"),
vm.content.mluvnice.map { m ->
val n = m.int("id")
LessonRow(m.str("name"), vm.progress.mluvDone(n), Route.MluvEx(n))
},
)
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)
}
}
AnimatedVisibility(
visible = vm.searchOpen,
enter = fadeIn(tween(160)),
exit = fadeOut(tween(120)),
) {
Box(Modifier.fillMaxSize()) { SearchScreen(vm) }
}
}
}

View file

@ -9,26 +9,20 @@ import android.os.Looper
import android.provider.Settings
import androidx.core.content.FileProvider
import org.maturita.maturita.BuildConfig
import org.maturita.maturita.platform.AppUpdater
import org.maturita.maturita.platform.UpdateState
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,
)
/**
* Self-update from the `builds` branch. CI rewrites that branch on every
* push to main with a commit titled "Build from <sha>"; the APK there is
* signed with the repository's sideload key, so Android accepts it as an
* update over any earlier build signed the same way.
*/
class Updater(private val app: Application) {
class Updater(private val app: Application) : AppUpdater {
private val io = Executors.newSingleThreadExecutor()
private val main = Handler(Looper.getMainLooper())
private val repo = BuildConfig.UPDATE_REPO
@ -38,7 +32,7 @@ class Updater(private val app: Application) {
main.post { cb(state) }
}
fun check(interactive: Boolean, cb: (UpdateState) -> Unit) {
override fun check(interactive: Boolean, cb: (UpdateState) -> Unit) {
if (BuildConfig.COMMIT == "dev") {
emit(cb, UpdateState(status = "dev", messageKey = "update_err_devbuild", messageArg = null))
return
@ -63,7 +57,7 @@ class Updater(private val app: Application) {
}
}
fun install(cb: (UpdateState) -> Unit) {
override fun install(cb: (UpdateState) -> Unit) {
// Android 8+ needs a per-app opt-in before we may hand an APK to the
// package installer. Send the user there and keep the Update button
// so the second tap goes straight to the download.

View file

@ -1,4 +1,6 @@
plugins {
id("com.android.application") version "9.1.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false
id("org.jetbrains.kotlin.jvm") version "2.2.10" apply false
id("org.jetbrains.compose") version "1.9.3" apply false
}

View file

@ -0,0 +1,103 @@
// Linux and Windows app: the same Compose UI as Android (../shared), packaged
// with its own Java runtime by jpackage.
plugins {
id("org.jetbrains.kotlin.jvm")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose")
}
val commit: String = System.getenv("COMMIT")?.takeIf { it.isNotBlank() } ?: "dev"
val repoRoot: File = rootProject.projectDir.parentFile
val generated = layout.buildDirectory.dir("generated/resources")
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
sourceSets.named("main") {
kotlin.srcDir("../shared/src")
}
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets.named("main") {
resources.srcDir(generated)
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3")
implementation("org.json:json:20250517")
testImplementation(kotlin("test"))
}
val buildContent = tasks.register<Exec>("buildContent") {
val out = generated.get().file("content.json").asFile
workingDir = repoRoot
val python = System.getenv("PYTHON")?.takeIf { it.isNotBlank() }
?: if (System.getProperty("os.name").startsWith("Windows")) "python" else "python3"
commandLine(python, "scripts/build-content.py", "--out", out.path)
inputs.dir(repoRoot.resolve("content"))
inputs.file(repoRoot.resolve("scripts/build-content.py"))
outputs.file(out)
}
val buildInfo = tasks.register("buildInfo") {
val out = generated.get().file("build-info.properties").asFile
inputs.property("commit", commit)
outputs.file(out)
doLast {
out.parentFile.mkdirs()
out.writeText("commit=$commit\n")
}
}
val appIcon = tasks.register<Copy>("appIcon") {
from(repoRoot.resolve("data/share/icons/hicolor/256x256/apps/maturita.png"))
into(generated)
rename { "icon.png" }
}
tasks.named("processResources") {
dependsOn(buildContent, buildInfo, appIcon)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
systemProperty("graduately.repo", repoRoot.path)
}
compose.desktop {
application {
mainClass = "org.maturita.maturita.desktop.MainKt"
// jpackage and ProGuard need a JDK with jmods (Temurin 21 has them, 24+ may not).
System.getenv("PACKAGE_JDK")?.takeIf { it.isNotBlank() }?.let { javaHome = it }
// Shrinking drops the thousands of unused icons and keeps the
// packages under GitHub's 100 MB file limit.
buildTypes.release.proguard {
version.set("7.10.0")
obfuscate.set(false)
configurationFiles.from(project.file("proguard-rules.pro"))
}
nativeDistributions {
// The launcher must stay graduately(.exe): installers and the
// updater of older builds start it by that name.
packageName = "graduately"
packageVersion = "1.0.0"
description = "Graduately"
vendor = "Graduately"
modules("java.naming", "jdk.crypto.ec", "jdk.unsupported")
linux {
iconFile.set(repoRoot.resolve("data/share/icons/hicolor/512x512/apps/maturita.png"))
}
windows {
iconFile.set(repoRoot.resolve("assets/app-icon.ico"))
}
}
}
}

2
android/desktop/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,2 @@
-dontwarn org.slf4j.**
-dontnote **

View file

@ -0,0 +1,17 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.platform.Platform
import org.maturita.maturita.platform.Prefs
import org.maturita.maturita.platform.Speaker
class DesktopPlatform(
override val commit: String,
override val prefs: Prefs,
install: Install,
) : Platform {
override val versionName: String = if (Install.isWindows) "windows" else "linux"
override val welcomeKey = "welcome_body_desktop"
override val updater = DesktopUpdater(commit, install)
override fun speaker(): Speaker? = DesktopSpeaker.create()
}

View file

@ -0,0 +1,99 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.platform.Speaker
import java.io.File
import java.util.Locale
import javax.swing.SwingUtilities
import kotlin.concurrent.thread
/**
* Speech through whatever the system already has: SAPI via PowerShell on
* Windows, espeak-ng / espeak / spd-say on Linux, `say` on a Mac. None of them
* report word positions, so pause and resume start the passage again.
*/
class DesktopSpeaker private constructor(private val command: (String, Locale, Float, Float) -> List<String>) : Speaker {
@Volatile private var process: Process? = null
override fun speak(
text: String,
locale: Locale,
rate: Float,
volume: Float,
onRange: (Int) -> Unit,
onEnd: (Boolean) -> Unit,
) {
stop()
val argv = command(text, locale, rate, volume)
val stdin = argv.lastOrNull() == STDIN
val p = runCatching {
ProcessBuilder(if (stdin) argv.dropLast(1) else argv)
.redirectErrorStream(true)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.start()
}.getOrElse {
SwingUtilities.invokeLater { onEnd(false) }
return
}
process = p
thread(isDaemon = true, name = "speech") {
if (stdin) runCatching { p.outputStream.writer(Charsets.UTF_8).use { it.write(text) } }
val ok = p.waitFor() == 0
if (process === p) {
process = null
SwingUtilities.invokeLater { onEnd(ok) }
}
}
}
override fun stop() {
process?.let {
process = null
it.destroy()
}
}
override fun close() = stop()
companion object {
/** Marks a command that reads the text from standard input. */
private const val STDIN = "\u0000stdin"
private fun onPath(name: String): Boolean =
System.getenv("PATH").orEmpty().split(File.pathSeparator).any { File(it, name).canExecute() }
fun create(): DesktopSpeaker? {
val os = System.getProperty("os.name").orEmpty()
return when {
os.startsWith("Windows") -> DesktopSpeaker { _, locale, rate, volume ->
val sapiRate = ((rate - 1f) * 10f).toInt().coerceIn(-10, 10)
val script = "[Console]::InputEncoding=[Text.Encoding]::UTF8;" +
"Add-Type -AssemblyName System.Speech;" +
"\$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" +
"try{\$s.SelectVoiceByHints('NotSet','NotSet',0,[Globalization.CultureInfo]'${locale.toLanguageTag()}')}catch{};" +
"\$s.Rate=$sapiRate;\$s.Volume=${(volume * 100).toInt()};" +
"\$s.Speak([Console]::In.ReadToEnd())"
listOf("powershell", "-NoProfile", "-NonInteractive", "-Command", script, STDIN)
}
os.startsWith("Mac") -> DesktopSpeaker { text, _, rate, _ ->
listOf("say", "-r", (180 * rate).toInt().toString(), text)
}
onPath("espeak-ng") || onPath("espeak") -> {
val bin = if (onPath("espeak-ng")) "espeak-ng" else "espeak"
DesktopSpeaker { _, locale, rate, volume ->
val voice = if (locale.language == "de") "de" else "en-gb"
listOf(bin, "-v", voice, "-s", (165 * rate).toInt().toString(), "-a", (volume * 150).toInt().toString(), "--stdin", STDIN)
}
}
onPath("spd-say") -> DesktopSpeaker { text, locale, rate, volume ->
listOf(
"spd-say", "-w", "-l", locale.language,
"-r", ((rate - 1f) * 100f).toInt().coerceIn(-100, 100).toString(),
"-i", ((volume - 1f) * 100f).toInt().coerceIn(-100, 100).toString(),
text,
)
}
else -> null
}
}
}
}

View file

@ -0,0 +1,220 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.platform.AppUpdater
import org.maturita.maturita.platform.UpdateState
import java.io.File
import java.net.HttpURLConnection
import java.net.URI
import java.util.concurrent.Executors
import java.util.zip.ZipInputStream
import javax.swing.SwingUtilities
import kotlin.system.exitProcess
/**
* Self-update from the `builds` branch, the same scheme the GTK builds used,
* so their updater and this one hand over cleanly: the asset names stay, and
* progress/ next to the app survives the swap.
*
* raw.githubusercontent.com caches branch-named URLs, so the branch tip is
* read from the Atom feed and every file is fetched from that commit. A running
* app cannot replace itself; a small script waits for this process to exit,
* swaps the new version in and starts it.
*/
class DesktopUpdater(private val commit: String, private val install: Install) : AppUpdater {
private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "updater").apply { isDaemon = true } }
private val repo = "pepaskopec-blip/Graduately"
private val branch = "builds"
private val platform = if (Install.isWindows) "windows" else "linux"
private val asset = if (Install.isWindows) "graduately-windows-x64.zip" else "graduately-linux-x86_64.AppImage"
private fun emit(cb: (UpdateState) -> Unit, state: UpdateState) = SwingUtilities.invokeLater { cb(state) }
override fun check(interactive: Boolean, cb: (UpdateState) -> Unit) {
if (!commit.matches(Regex("[0-9a-fA-F]{7,40}"))) {
emit(cb, UpdateState("dev", messageKey = "update_err_devbuild"))
return
}
if (interactive) emit(cb, UpdateState("checking", messageKey = "update_checking"))
io.execute {
try {
val atom = get("https://github.com/$repo/commits/$branch.atom")
val tip = atomTip(atom) ?: error("no tip")
val remote = remoteCommit(atom, tip)
emit(cb, when {
sameCommit(remote, commit) -> UpdateState("ok", remote, "update_uptodate", remote.take(7))
install.kind == Install.Kind.Unsupported ->
UpdateState("unsupported", remote, if (interactive) "update_err_unsupported" else "update_current", commit.take(7))
else -> UpdateState("available", remote, "update_available", remote.take(7), canInstall = true)
})
} catch (_: Exception) {
emit(cb, if (interactive) UpdateState("error", messageKey = "update_err_network")
else UpdateState(messageArg = commit.take(7)))
}
}
}
override fun install(cb: (UpdateState) -> Unit) {
val target = install.target
if (install.kind == Install.Kind.Unsupported || target == null) {
emit(cb, UpdateState("error", messageKey = "update_err_unsupported"))
return
}
emit(cb, UpdateState("downloading", messageKey = "update_downloading"))
io.execute {
val staging = File(System.getProperty("java.io.tmpdir"), "graduately-update")
val source: File
try {
val tip = atomTip(get("https://github.com/$repo/commits/$branch.atom")) ?: error("no tip")
staging.deleteRecursively()
staging.mkdirs()
val file = File(staging, asset)
download("https://raw.githubusercontent.com/$repo/$tip/$asset", file)
source = if (install.kind == Install.Kind.WindowsDir) {
File(staging, "new").also { unzip(file, it) }
.takeIf { File(it, "graduately.exe").isFile } ?: error("bad zip")
} else {
file.takeIf { isElf(it) }?.also { it.setExecutable(true) } ?: error("bad AppImage")
}
} catch (_: Exception) {
staging.deleteRecursively()
emit(cb, UpdateState("error", messageKey = "update_err_download", canInstall = true))
return@execute
}
try {
startSwap(target, source, staging)
} catch (_: Exception) {
emit(cb, UpdateState("error", messageKey = "update_err_stage", canInstall = true))
return@execute
}
emit(cb, UpdateState("staged", messageKey = "update_staged"))
SwingUtilities.invokeLater { exitProcess(0) }
}
}
private fun startSwap(target: File, source: File, staging: File) {
val pid = ProcessHandle.current().pid().toString()
val windows = install.kind == Install.Kind.WindowsDir
val script = File(System.getProperty("java.io.tmpdir"), if (windows) "graduately-update.cmd" else "graduately-update.sh")
script.writeText(if (windows) WINDOWS_SWAP else UNIX_SWAP)
script.setExecutable(true)
val argv = if (windows) listOf("cmd", "/c", script.path) else listOf("/bin/sh", script.path)
val pb = ProcessBuilder(argv + listOf(pid, target.path, source.path, staging.path))
// The new AppImage must get its own mount, not inherit this one.
pb.environment().keys.removeAll(setOf("APPIMAGE", "APPDIR", "ARGV0", "OWD"))
val p = pb.redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start()
// The script detaches itself and exits; wait so we do not quit first.
p.waitFor()
}
/** Commit whose package for this platform sits on the branch. */
private fun remoteCommit(atom: String, tip: String): String {
val marker = runCatching { get("https://raw.githubusercontent.com/$repo/$tip/VERSION-$platform").trim() }.getOrNull()
if (marker != null && marker.length >= 7) return marker
return atomMainSha(atom) ?: get("https://raw.githubusercontent.com/$repo/$tip/VERSION").trim()
}
private fun atomTip(atom: String): String? =
Regex("""Commit/([0-9a-f]{40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1)
private fun atomMainSha(atom: String): String? =
Regex("""Build from ([0-9a-f]{7,40})""", RegexOption.IGNORE_CASE).find(atom)?.groupValues?.get(1)
private fun sameCommit(a: String, b: String): Boolean {
val n = minOf(a.length, b.length)
return n >= 7 && a.take(n).equals(b.take(n), ignoreCase = true)
}
private fun isElf(f: File): Boolean = f.length() > 1_000_000L &&
f.inputStream().use { val h = ByteArray(4); it.read(h) == 4 && h.contentEquals(byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())) }
private fun unzip(zip: File, dest: File) {
val root = dest.canonicalFile
ZipInputStream(zip.inputStream().buffered()).use { z ->
while (true) {
val e = z.nextEntry ?: break
val out = File(root, e.name).canonicalFile
if (!out.path.startsWith(root.path + File.separator)) error("zip slip")
if (e.isDirectory) out.mkdirs() else {
out.parentFile.mkdirs()
out.outputStream().use { z.copyTo(it) }
}
}
}
}
private fun open(url: String, readMs: Int): HttpURLConnection =
(URI(url).toURL().openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = readMs
instanceFollowRedirects = true
setRequestProperty("User-Agent", "Graduately-desktop")
}
private fun get(url: String): String {
val c = open(url, 15000)
try {
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
return c.inputStream.bufferedReader().use { it.readText() }
} finally {
c.disconnect()
}
}
private fun download(url: String, dest: File) {
val c = open(url, 60000)
try {
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
c.inputStream.use { input -> dest.outputStream().use { input.copyTo(it) } }
} finally {
c.disconnect()
}
}
private companion object {
// Arguments: pid target source staging.
const val UNIX_SWAP = """#!/bin/sh
if [ -z "${'$'}GRADUATELY_UPDATE_DETACHED" ]; then
GRADUATELY_UPDATE_DETACHED=1
export GRADUATELY_UPDATE_DETACHED
nohup /bin/sh "${'$'}0" "${'$'}@" >/dev/null 2>&1 &
exit 0
fi
pid=${'$'}1; target=${'$'}2; source=${'$'}3; staging=${'$'}4
n=0
while kill -0 "${'$'}pid" 2>/dev/null && [ ${'$'}n -lt 600 ]; do
sleep 0.2; n=${'$'}((n+1))
done
if mv -f "${'$'}source" "${'$'}target" 2>/dev/null; then
chmod +x "${'$'}target"
fi
"${'$'}target" >/dev/null 2>&1 &
rm -rf "${'$'}staging"
rm -f "${'$'}0"
"""
// robocopy mirrors the new build over the old one (so nothing stale is
// left behind) but never touches progress/. ping is a sleep without a
// console window.
val WINDOWS_SWAP = """@echo off
if not defined GRADUATELY_UPDATE_DETACHED (
set GRADUATELY_UPDATE_DETACHED=1
start "" /b cmd /c "%~f0" %*
exit /b 0
)
set "PID=%~1"
set "TARGET=%~2"
set "SOURCE=%~3"
set "STAGING=%~4"
:wait
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
if not errorlevel 1 (
ping -n 2 127.0.0.1 >nul
goto wait
)
robocopy "%SOURCE%" "%TARGET%" /MIR /XD progress /R:5 /W:1 /NFL /NDL /NJH /NJS /NP >nul
start "" "%TARGET%\graduately.exe"
rmdir /s /q "%STAGING%"
(goto) 2>nul & del "%~f0"
""".replace("\n", "\r\n")
}
}

View file

@ -0,0 +1,46 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.platform.Prefs
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.Properties
/** Settings and progress in one properties file, rewritten atomically on every change. */
class FilePrefs(private val file: File) : Prefs {
private val values = Properties()
init {
runCatching { file.inputStream().use { values.load(it.reader(Charsets.UTF_8)) } }
}
override fun getBoolean(key: String, fallback: Boolean) =
values.getProperty(key)?.toBooleanStrictOrNull() ?: fallback
override fun getInt(key: String, fallback: Int) =
values.getProperty(key)?.toIntOrNull() ?: fallback
override fun getString(key: String, fallback: String?): String? =
values.getProperty(key) ?: fallback
override fun edit(): Prefs.Editor = Editor()
@Synchronized
private fun save(changes: Map<String, String>) {
values.putAll(changes)
runCatching {
file.parentFile?.mkdirs()
val tmp = File(file.path + ".tmp")
tmp.writer(Charsets.UTF_8).use { values.store(it, null) }
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
}
}
private inner class Editor : Prefs.Editor {
private val changes = LinkedHashMap<String, String>()
override fun putBoolean(key: String, value: Boolean) = apply { changes[key] = value.toString() }
override fun putInt(key: String, value: Int) = apply { changes[key] = value.toString() }
override fun putString(key: String, value: String) = apply { changes[key] = value }
override fun apply() = save(changes)
}
}

View file

@ -0,0 +1,96 @@
package org.maturita.maturita.desktop
import org.maturita.maturita.data.ThemeId
import org.maturita.maturita.data.themeNames
import org.maturita.maturita.platform.Prefs
import java.io.File
/**
* The GTK builds kept progress in `progress/<course>.conf` key files. Copy it
* once into the shared progress keys (`net.3`, `en.2.5`, `g.0.vocab` …); the
* shared ProgressStore then moves those onto lesson ids like on every
* platform. The .conf files stay, so nothing is lost if the import misreads.
*/
fun importGtkProgress(dir: File, prefs: Prefs) {
if (prefs.getBoolean("gtk_imported", false)) return
val edit = prefs.edit()
for (file in dir.listFiles { f -> f.name.endsWith(".conf") }.orEmpty()) {
val course = file.name.removeSuffix(".conf")
val groups = readKeyFile(file)
if (course == "settings") {
groups["ui"]?.let { importSettings(it, edit) }
continue
}
for ((key, value) in groups["done"].orEmpty()) {
if (value != "true") continue
gtkAddress(course, key)?.let { edit.putBoolean(it, true) }
}
}
edit.putBoolean("gtk_imported", true).apply()
}
/** GTK file and key to the address the apps use, or null for what has no equivalent. */
internal fun gtkAddress(course: String, key: String): String? {
val unit = Regex("""unit(\d)""").matchEntire(course)?.groupValues?.get(1)?.toInt()
return when {
// The vocabulary branch used the last slot of the unit.
unit != null -> key.toIntOrNull()?.let { if (it == 20) "g.${unit - 1}.vocab" else "g.${unit - 1}.$it" }
course == "english" -> yearKey("en", key)
course == "german-course" -> yearKey("de", key)
course == "mluvnice" -> key.toIntOrNull()?.let { "mluv.$it" }
// Book progress was one shared slot, not per book.
course == "cetba" -> null
else -> key.toIntOrNull()?.let { "$course.$it" }
}
}
private fun yearKey(prefix: String, key: String): String? {
val (year, lesson) = key.split("-").takeIf { it.size == 2 } ?: return null
if (year.toIntOrNull() == null || lesson.toIntOrNull() == null) return null
return "$prefix.$year.$lesson"
}
private fun importSettings(ui: Map<String, String>, edit: Prefs.Editor) {
ui["theme"]?.let { name ->
val i = themeNames.indexOfFirst { it.equals(name, ignoreCase = true) }
if (i >= 0) edit.putInt("theme", ThemeId.entries[i].ordinal)
}
ui["mode"]?.let { edit.putString("mode", if (it.lowercase() in setOf("light", "white")) "light" else "dark") }
ui["lang"]?.let { edit.putString("lang", if (it.lowercase() in setOf("en", "english")) "en" else "cs") }
ui["seen_commit"]?.takeIf { it.isNotBlank() }?.let { edit.putString("seen_commit", it) }
}
internal fun readKeyFile(file: File): Map<String, Map<String, String>> {
val groups = LinkedHashMap<String, MutableMap<String, String>>()
var current: MutableMap<String, String>? = null
runCatching { file.readLines() }.getOrDefault(emptyList()).forEach { raw ->
val line = raw.trim()
when {
line.isEmpty() || line.startsWith("#") -> {}
line.startsWith("[") && line.endsWith("]") ->
current = groups.getOrPut(line.substring(1, line.length - 1)) { LinkedHashMap() }
'=' in line -> current?.put(line.substringBefore('=').trim(), line.substringAfter('=').trim())
}
}
return groups
}
/**
* The GTK updater copied new packages over the install without deleting
* anything, so a Windows folder updated from GTK still carries its DLLs and
* data. style.css marks such a folder; jpackage never writes one.
*/
fun removeGtkLeftovers(install: Install) {
if (install.kind != Install.Kind.WindowsDir) return
val dir = install.target ?: return
val marker = File(dir, "style.css")
if (!marker.isFile) return
dir.listFiles().orEmpty().forEach { f ->
val name = f.name.lowercase()
val gtkDll = name.endsWith(".dll") && (name.startsWith("lib") || name == "zlib1.dll")
val stale = (f.isFile && (gtkDll || name == "changelog.txt")) ||
(f.isDirectory && name in setOf("lib", "share", "icons"))
if (stale) f.deleteRecursively()
}
marker.delete()
}

View file

@ -0,0 +1,40 @@
package org.maturita.maturita.desktop
import java.io.File
/**
* How this copy runs, which decides where progress lives and what an update
* replaces. Progress sits next to the app, as it did in the GTK builds, so
* existing students keep it: beside the AppImage on Linux, inside the install
* directory on Windows.
*/
class Install(
val kind: Kind,
/** The AppImage file, or the Windows directory holding graduately.exe. */
val target: File?,
val progressDir: File,
) {
enum class Kind { AppImage, WindowsDir, Unsupported }
companion object {
val isWindows = System.getProperty("os.name").orEmpty().startsWith("Windows")
fun detect(): Install {
val appImage = System.getenv("APPIMAGE")?.takeIf { it.isNotBlank() }?.let(::File)
val launcher = System.getProperty("jpackage.app-path")?.let(::File)
val windowsDir = launcher?.parentFile?.takeIf {
isWindows && launcher.name.equals("graduately.exe", ignoreCase = true) &&
File(it, "app").isDirectory && File(it, "runtime").isDirectory
}
val (kind, target) = when {
appImage != null && !isWindows -> Kind.AppImage to appImage
windowsDir != null -> Kind.WindowsDir to windowsDir
else -> Kind.Unsupported to null
}
val beside = target?.let { if (kind == Kind.AppImage) it.parentFile else it }
val home = File(System.getProperty("user.home"), ".graduately")
val root = beside?.takeIf { it.canWrite() } ?: home
return Install(kind, target, File(root, "progress"))
}
}
}

View file

@ -0,0 +1,72 @@
package org.maturita.maturita.desktop
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import org.maturita.maturita.AppViewModel
import org.maturita.maturita.MaturitaApp
import org.maturita.maturita.data.Content
import org.maturita.maturita.handleBack
import java.io.File
import java.util.Properties
import javax.imageio.ImageIO
private fun resourceText(name: String): String? =
Thread.currentThread().contextClassLoader.getResourceAsStream(name)?.bufferedReader()?.use { it.readText() }
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
val install = Install.detect()
val prefs = FilePrefs(File(install.progressDir, "graduately.properties"))
importGtkProgress(install.progressDir, prefs)
removeGtkLeftovers(install)
val info = Properties().apply { resourceText("build-info.properties")?.let { load(it.reader()) } }
val platform = DesktopPlatform(info.getProperty("commit") ?: "dev", prefs, install)
val vm = AppViewModel(Content.parse(resourceText("content.json")), platform)
val icon = Thread.currentThread().contextClassLoader.getResourceAsStream("icon.png")
?.use { ImageIO.read(it) }?.let { BitmapPainter(it.toComposeImageBitmap()) }
application {
Window(
onCloseRequest = ::exitApplication,
title = "Graduately",
icon = icon,
state = rememberWindowState(width = 1100.dp, height = 780.dp),
onPreviewKeyEvent = { e ->
if (e.type != KeyEventType.KeyDown) return@Window false
val cmd = e.isCtrlPressed || e.isMetaPressed
when {
e.key == Key.Escape -> vm.handleBack()
cmd && e.key == Key.K -> { vm.searchOpen = true; true }
cmd && e.key == Key.Q -> { exitApplication(); true }
else -> false
}
},
) {
Box(
Modifier.fillMaxSize().onPointerEvent(PointerEventType.Press) { e ->
if (e.button == PointerButton.Back) vm.handleBack()
},
) {
MaturitaApp(vm)
}
}
}
}

View file

@ -0,0 +1,86 @@
package org.maturita.maturita
import org.json.JSONArray
import org.json.JSONObject
import org.maturita.maturita.data.answerAccepts
import org.maturita.maturita.data.answerIncomplete
import org.maturita.maturita.data.hasUmlaut
import org.maturita.maturita.data.mathAnswerOk
import org.maturita.maturita.data.mathDrawGiven
import org.maturita.maturita.data.mathDrawNeed
import org.maturita.maturita.data.mathDrawOk
import org.maturita.maturita.data.mathIsDraw
import org.maturita.maturita.data.netTxtEq
import org.maturita.maturita.data.normalizeAnswer
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Runs tests/answers.json, the cases the Swift apps check too (tests/swift). */
class AnswersTest {
private val cases = JSONObject(File(System.getProperty("graduately.repo"), "tests/answers.json").readText())
private fun rows(name: String): List<JSONArray> =
cases.getJSONArray(name).let { a -> (0 until a.length()).map { a.getJSONArray(it) } }
private fun JSONArray.str(i: Int): String? = if (isNull(i)) null else getString(i)
private fun JSONArray.points(i: Int): List<Pair<Int, Int>> =
getJSONArray(i).let { a -> (0 until a.length()).map { a.getJSONArray(it).let { p -> p.getInt(0) to p.getInt(1) } } }
@Test
fun normalize() = rows("normalize").forEach { assertEquals(it.str(1), normalizeAnswer(it.str(0)), "normalize ${it.str(0)}") }
@Test
fun accepts() = rows("accepts").forEach {
assertEquals(it.getBoolean(2), answerAccepts(normalizeAnswer(it.str(0)), it.str(1)), "accepts $it")
}
@Test
fun incomplete() = rows("incomplete").forEach {
assertEquals(it.getBoolean(2), answerIncomplete(normalizeAnswer(it.str(0)), it.str(1)), "incomplete $it")
}
@Test
fun netText() = rows("netTxtEq").forEach { assertEquals(it.getBoolean(2), netTxtEq(it.str(0), it.str(1)), "netTxtEq $it") }
@Test
fun umlaut() = rows("hasUmlaut").forEach { assertEquals(it.getBoolean(1), hasUmlaut(it.str(0)), "hasUmlaut $it") }
@Test
fun math() = rows("math").forEach { assertEquals(it.getBoolean(2), mathAnswerOk(it.str(0), it.str(1)), "math $it") }
@Test
fun draw() {
rows("drawNeed").forEach { assertEquals(it.getInt(1), mathDrawNeed(it.str(0)), "drawNeed $it") }
rows("drawGiven").forEach { assertEquals(it.points(1), mathDrawGiven(it.str(0)), "drawGiven $it") }
rows("draw").forEach { assertEquals(it.getBoolean(2), mathDrawOk(it.str(0), it.points(1)), "draw $it") }
}
/** Every worked answer in content/ must pass its own check, or students could never solve it. */
@Test
fun contentAnswersSolveThemselves() {
val root = JSONObject(javaClass.classLoader.getResource("content.json")!!.readText())
var checked = 0
for (section in listOf("mat0", "mat", "mat2", "mat3", "mat4")) {
val lessons = root.getJSONArray(section)
for (l in 0 until lessons.length()) {
val lesson = lessons.getJSONObject(l)
val problems = lesson.getJSONArray("problems")
for (p in 0 until problems.length()) {
val spec = problems.getJSONObject(p).getString("answer")
val where = "$section ${lesson.getString("key")}: $spec"
if (mathIsDraw(spec)) {
assertTrue(mathDrawNeed(spec) > 0, where)
} else {
val body = spec.substringAfter(':').substringBefore('|')
assertTrue(mathAnswerOk(spec, body), where)
}
checked++
}
}
}
assertTrue(checked > 100, "only $checked problems found")
}
}

View file

@ -0,0 +1,77 @@
package org.maturita.maturita
import org.json.JSONObject
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.desktop.FilePrefs
import org.maturita.maturita.desktop.gtkAddress
import org.maturita.maturita.desktop.importGtkProgress
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ProgressTest {
private val content = Content.parse(javaClass.classLoader.getResource("content.json")!!.readText())
@Test
fun everyLegacyAddressHasALesson() {
val root = JSONObject(javaClass.classLoader.getResource("content.json")!!.readText())
val keys = root.getJSONObject("progress").getJSONObject("keys")
assertTrue(content.progressLegacy.isNotEmpty())
for ((old, id) in content.progressLegacy) {
assertTrue(keys.keySet().any { keys.getString(it) == id }, "$old -> $id has no lesson")
}
}
@Test
fun oldProgressMovesToLessonIds() {
val dir = Files.createTempDirectory("progress").toFile()
val prefs = FilePrefs(dir.resolve("p.properties"))
prefs.edit().putBoolean("fyz.1", true).putBoolean("g.0.vocab", true).putBoolean("en.2.3", true).apply()
val store = ProgressStore(prefs, content)
assertTrue(store.fyzDone(1))
assertTrue(store.vocabDone(0))
assertTrue(store.enDone(2, 3))
assertFalse(store.fyzDone(2))
assertTrue(prefs.getBoolean("done.fyzika-1-fyzikalni-veliciny", false))
store.markMat(1)
assertTrue(FilePrefs(dir.resolve("p.properties")).getBoolean("done.matematika-1-delitelnost-a-prvocisla", false))
}
@Test
fun gtkProgressIsImported() {
val dir = Files.createTempDirectory("gtk").toFile()
dir.resolve("net.conf").writeText("[done]\n1=true\n2=false\n")
dir.resolve("unit1.conf").writeText("[done]\n1=true\n20=true\n")
dir.resolve("english.conf").writeText("[done]\n2-3=true\n")
dir.resolve("settings.conf").writeText("[ui]\ntheme=Nord\nmode=dark\nlang=en\nseen_commit=abcdef1\n")
val prefs = FilePrefs(dir.resolve("graduately.properties"))
importGtkProgress(dir, prefs)
val store = ProgressStore(prefs, content)
assertTrue(store.netDone(1))
assertFalse(store.netDone(2))
assertTrue(store.germanDone(0, 1))
assertTrue(store.vocabDone(0))
assertTrue(store.enDone(2, 3))
assertEquals(ThemeId.Nord, store.themeId)
assertEquals(ColorMode.Dark, store.mode)
assertEquals("abcdef1", store.seenCommit)
}
@Test
fun gtkAddresses() {
assertEquals("g.2.5", gtkAddress("unit3", "5"))
assertEquals("de.4.6", gtkAddress("german-course", "4-6"))
assertEquals("mluv.7", gtkAddress("mluvnice", "7"))
assertEquals("hw3.2", gtkAddress("hw3", "2"))
assertEquals(null, gtkAddress("cetba", "1"))
}
}

View file

@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "maturita"
include(":app")
include(":desktop")

View file

@ -0,0 +1,580 @@
package org.maturita.maturita
import java.util.Locale
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.data.Palette
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.HomeScreen
import org.maturita.maturita.ui.HwQuizScreen
import org.maturita.maturita.ui.HwYearsScreen
import org.maturita.maturita.ui.LessonListScreen
import org.maturita.maturita.ui.FyzQuizScreen
import org.maturita.maturita.ui.MathPracticeScreen
import org.maturita.maturita.ui.MatYearsScreen
import org.maturita.maturita.ui.FyzYearsScreen
import org.maturita.maturita.ui.LitYearsScreen
import org.maturita.maturita.ui.LessonRow
import org.maturita.maturita.ui.LitQuizScreen
import org.maturita.maturita.ui.SciMapScreen
import org.maturita.maturita.ui.SciQuizScreen
import org.maturita.maturita.ui.MluvExercise
import org.maturita.maturita.ui.NetQuizScreen
import org.maturita.maturita.ui.NetYearsScreen
import org.maturita.maturita.ui.On2QuizScreen
import org.maturita.maturita.ui.On3QuizScreen
import org.maturita.maturita.ui.On4QuizScreen
import org.maturita.maturita.ui.OnQuizScreen
import org.maturita.maturita.ui.OnYearsScreen
import org.maturita.maturita.ui.EnYearsScreen
import org.maturita.maturita.ui.ChoiceLessonScreen
import org.maturita.maturita.ui.DeHomeScreen
import org.maturita.maturita.ui.ItYearScreen
import org.maturita.maturita.ui.EngExScreen
import org.maturita.maturita.ui.PlotScreen
import org.maturita.maturita.ui.RoadmapScreen
import org.maturita.maturita.ui.SearchScreen
import org.maturita.maturita.ui.SettingsScreen
import org.maturita.maturita.ui.SlidesScreen
import org.maturita.maturita.ui.StatsScreen
import org.maturita.maturita.ui.UnitMapScreen
import org.maturita.maturita.ui.VocabExercise
import org.maturita.maturita.ui.appTypography
/** Material 3 colour roles from the shared theme palette. */
private fun schemeFor(p: Palette, dark: Boolean) = if (dark) {
darkColorScheme(
primary = p.tint,
onPrimary = p.onTint,
primaryContainer = p.accent,
onPrimaryContainer = p.onAccent,
secondary = p.accent2,
onSecondary = p.onAccent,
secondaryContainer = p.surface1,
onSecondaryContainer = p.text,
tertiary = p.warning,
onTertiary = p.crust,
background = p.base,
onBackground = p.text,
surface = p.base,
onSurface = p.text,
surfaceVariant = p.surface0,
onSurfaceVariant = p.subtext,
surfaceContainerLowest = p.crust,
surfaceContainerLow = p.mantle,
surfaceContainer = p.mantle,
surfaceContainerHigh = p.surface0,
surfaceContainerHighest = p.surface1,
outline = p.overlay,
outlineVariant = p.surface1,
error = p.error,
onError = Color.White,
errorContainer = p.error.copy(alpha = 0.22f),
onErrorContainer = p.text,
)
} else {
lightColorScheme(
primary = p.tint,
onPrimary = p.onTint,
primaryContainer = p.accent,
onPrimaryContainer = p.onAccent,
secondary = p.accent2,
onSecondary = Color.White,
secondaryContainer = p.surface1,
onSecondaryContainer = p.text,
tertiary = p.warning,
onTertiary = Color.White,
background = p.base,
onBackground = p.text,
surface = p.base,
onSurface = p.text,
surfaceVariant = p.surface1,
onSurfaceVariant = p.subtext,
surfaceContainerLowest = p.mantle,
surfaceContainerLow = p.mantle,
surfaceContainer = p.mantle,
surfaceContainerHigh = p.surface1,
surfaceContainerHighest = p.surface2,
outline = p.overlay,
outlineVariant = p.surface2,
error = p.error,
onError = Color.White,
errorContainer = p.error.copy(alpha = 0.14f),
onErrorContainer = p.text,
)
}
@Composable
fun MaturitaApp(vm: AppViewModel) {
MaterialTheme(colorScheme = schemeFor(vm.palette, vm.mode == ColorMode.Dark), typography = appTypography()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MaturitaAppBody(vm)
}
}
}
/** Back gesture, Escape or the mouse back button: close search first, then walk up. */
fun AppViewModel.handleBack(): Boolean {
if (searchOpen) {
searchOpen = false
return true
}
if (!canGoBack) return false
back()
return true
}
@Composable
private fun MaturitaAppBody(vm: AppViewModel) {
LaunchedEffect(Unit) { vm.checkUpdate(false) }
Box(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = vm.route,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
val tabs = targetState.isRoot && initialState.isRoot
if (tabs) {
fadeIn(tween(180)) togetherWith fadeOut(tween(140))
} else if (vm.navigatingForward) {
(fadeIn(tween(220)) + slideInHorizontally(tween(220)) { it / 5 })
.togetherWith(fadeOut(tween(150)) + slideOutHorizontally(tween(150)) { -it / 8 })
} else {
(fadeIn(tween(220)) + slideInHorizontally(tween(220)) { -it / 8 })
.togetherWith(fadeOut(tween(150)) + slideOutHorizontally(tween(150)) { it / 5 })
}
},
label = "screen",
) { route ->
when (val r = route) {
Route.Welcome, Route.Subjects -> HomeScreen(vm)
Route.Stats -> StatsScreen(vm)
Route.Settings -> SettingsScreen(vm)
Route.DeHome -> DeHomeScreen(vm)
is Route.DeMap -> LessonListScreen(
vm, vm.tr("de_year${r.year}"), vm.tr("de_y${r.year}_sub"),
vm.content.deYear(r.year).map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.deDone(r.year, id), Route.DeLesson(r.year, id))
},
)
is Route.DeLesson -> {
val l = vm.content.deLesson(r.year, r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.DeEx(r.year, r.id))
}
}
is Route.DeEx -> EngExScreen(
vm, r.year, r.id, Locale.GERMANY, vm.content.deLesson(r.year, r.id),
) { vm.markDe(r.year, r.id) }
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 -> LessonListScreen(
vm, vm.tr("net_year1"), vm.tr("net_sub"),
vm.content.netLessons.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.netDone(id), Route.NetLesson(id))
},
)
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.replace(Route.NetEx(r.id))
}
}
is Route.NetEx -> NetQuizScreen(vm, r.id)
Route.Net2Map -> ItYearScreen(vm, "net2", "net_year2", "net2_sub") { Route.Net2Lesson(it) }
is Route.Net2Lesson -> {
val l = vm.content.itLesson("net2", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Net2Ex(r.id))
}
}
is Route.Net2Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("net2", r.id)) { vm.markCourse("net2", r.id) }
Route.Net3Map -> ItYearScreen(vm, "net3", "net_year3", "net3_sub") { Route.Net3Lesson(it) }
is Route.Net3Lesson -> {
val l = vm.content.itLesson("net3", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Net3Ex(r.id))
}
}
is Route.Net3Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("net3", r.id)) { vm.markCourse("net3", r.id) }
Route.Net4Map -> ItYearScreen(vm, "net4", "net_year4", "net4_sub") { Route.Net4Lesson(it) }
is Route.Net4Lesson -> {
val l = vm.content.itLesson("net4", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Net4Ex(r.id))
}
}
is Route.Net4Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("net4", r.id)) { vm.markCourse("net4", r.id) }
Route.HwYears -> HwYearsScreen(vm)
Route.HwMap -> LessonListScreen(
vm, vm.tr("hw_year1"), vm.tr("hw_sub"),
vm.content.hw.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.hwDone(id), Route.HwLesson(id))
},
)
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.replace(Route.HwEx(r.id))
}
}
is Route.HwEx -> HwQuizScreen(vm, r.id)
Route.Hw2Map -> ItYearScreen(vm, "hw2", "hw_year2", "hw2_sub") { Route.Hw2Lesson(it) }
is Route.Hw2Lesson -> {
val l = vm.content.itLesson("hw2", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Hw2Ex(r.id))
}
}
is Route.Hw2Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("hw2", r.id)) { vm.markCourse("hw2", r.id) }
Route.Hw3Map -> ItYearScreen(vm, "hw3", "hw_year3", "hw3_sub") { Route.Hw3Lesson(it) }
is Route.Hw3Lesson -> {
val l = vm.content.itLesson("hw3", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Hw3Ex(r.id))
}
}
is Route.Hw3Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("hw3", r.id)) { vm.markCourse("hw3", r.id) }
Route.Hw4Map -> ItYearScreen(vm, "hw4", "hw_year4", "hw4_sub") { Route.Hw4Lesson(it) }
is Route.Hw4Lesson -> {
val l = vm.content.itLesson("hw4", r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Hw4Ex(r.id))
}
}
is Route.Hw4Ex -> ChoiceLessonScreen(vm, vm.content.itLesson("hw4", r.id)) { vm.markCourse("hw4", r.id) }
Route.EnYears -> EnYearsScreen(vm)
is Route.EnMap -> LessonListScreen(
vm, vm.tr("en_year${r.year}"), vm.tr("en_y${r.year}_sub"),
vm.content.enYear(r.year).map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.enDone(r.year, id), Route.EnLesson(r.year, id))
},
)
is Route.EnLesson -> {
val l = vm.content.enLesson(r.year, r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.EnEx(r.year, r.id))
}
}
is Route.EnEx -> EngExScreen(vm, r.year, r.id)
Route.OnYears -> OnYearsScreen(vm)
Route.OnMap -> LessonListScreen(
vm, vm.tr("on_year1"), vm.tr("on_sub"),
vm.content.on.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.onDone(id), Route.OnLesson(id))
},
)
is Route.OnLesson -> {
val l = vm.content.onLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.OnEx(r.id))
}
}
is Route.OnEx -> OnQuizScreen(vm, r.id)
Route.On2Map -> LessonListScreen(
vm, vm.tr("on_year2"), vm.tr("on2_sub"),
vm.content.on2.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.on2Done(id), Route.On2Lesson(id))
},
)
is Route.On2Lesson -> {
val l = vm.content.on2Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.On2Ex(r.id))
}
}
is Route.On2Ex -> On2QuizScreen(vm, r.id)
Route.On3Map -> LessonListScreen(
vm, vm.tr("on_year3"), vm.tr("on3_sub"),
vm.content.on3.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.on3Done(id), Route.On3Lesson(id))
},
)
is Route.On3Lesson -> {
val l = vm.content.on3Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.On3Ex(r.id))
}
}
is Route.On3Ex -> On3QuizScreen(vm, r.id)
Route.On4Map -> LessonListScreen(
vm, vm.tr("on_year4"), vm.tr("on4_sub"),
vm.content.on4.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.on4Done(id), Route.On4Lesson(id))
},
)
is Route.On4Lesson -> {
val l = vm.content.on4Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.On4Ex(r.id))
}
}
is Route.On4Ex -> On4QuizScreen(vm, r.id)
Route.LitYears -> LitYearsScreen(vm)
Route.LitMap -> LessonListScreen(
vm, vm.tr("lit_year1"), vm.tr("lit_sub"),
vm.content.lit.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.litDone(id), Route.LitLesson(id))
},
)
is Route.LitLesson -> {
val l = vm.content.litLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.LitEx(r.id))
}
}
is Route.LitEx -> LitQuizScreen(vm, 1, r.id)
Route.Lit2Map -> LessonListScreen(
vm, vm.tr("lit_year2"), vm.tr("lit2_sub"),
vm.content.lit2.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.lit2Done(id), Route.Lit2Lesson(id))
},
)
is Route.Lit2Lesson -> {
val l = vm.content.lit2Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Lit2Ex(r.id))
}
}
is Route.Lit2Ex -> LitQuizScreen(vm, 2, r.id)
Route.Lit3Map -> LessonListScreen(
vm, vm.tr("lit_year3"), vm.tr("lit3_sub"),
vm.content.lit3.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.lit3Done(id), Route.Lit3Lesson(id))
},
)
is Route.Lit3Lesson -> {
val l = vm.content.lit3Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Lit3Ex(r.id))
}
}
is Route.Lit3Ex -> LitQuizScreen(vm, 3, r.id)
Route.Lit4Map -> LessonListScreen(
vm, vm.tr("lit_year4"), vm.tr("lit4_sub"),
vm.content.lit4.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.lit4Done(id), Route.Lit4Lesson(id))
},
)
is Route.Lit4Lesson -> {
val l = vm.content.lit4Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Lit4Ex(r.id))
}
}
is Route.Lit4Ex -> LitQuizScreen(vm, 4, r.id)
Route.MatYears -> MatYearsScreen(vm)
Route.Mat0Map -> LessonListScreen(
vm, vm.tr("mat0_year"), vm.tr("mat0_sub"),
vm.content.mat0.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat0Done(id), Route.Mat0Lesson(id))
},
)
is Route.Mat0Lesson -> {
val l = vm.content.mat0Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Mat0Ex(r.id))
}
}
is Route.Mat0Ex -> MathPracticeScreen(vm, 0, r.id)
Route.MatMap -> LessonListScreen(
vm, vm.tr("mat_year1"), vm.tr("mat_sub"),
vm.content.mat.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.matDone(id), Route.MatLesson(id))
},
)
is Route.MatLesson -> {
val l = vm.content.matLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.MatEx(r.id))
}
}
is Route.MatEx -> MathPracticeScreen(vm, 1, r.id)
Route.Mat2Map -> LessonListScreen(
vm, vm.tr("mat_year2"), vm.tr("mat2_sub"),
vm.content.mat2.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat2Done(id), Route.Mat2Lesson(id))
},
)
is Route.Mat2Lesson -> {
val l = vm.content.mat2Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Mat2Ex(r.id))
}
}
is Route.Mat2Ex -> MathPracticeScreen(vm, 2, r.id)
Route.Mat3Map -> LessonListScreen(
vm, vm.tr("mat_year3"), vm.tr("mat3_sub"),
vm.content.mat3.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat3Done(id), Route.Mat3Lesson(id))
},
)
is Route.Mat3Lesson -> {
val l = vm.content.mat3Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Mat3Ex(r.id))
}
}
is Route.Mat3Ex -> MathPracticeScreen(vm, 3, r.id)
Route.Mat4Map -> LessonListScreen(
vm, vm.tr("mat_year4"), vm.tr("mat4_sub"),
vm.content.mat4.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.mat4Done(id), Route.Mat4Lesson(id))
},
)
is Route.Mat4Lesson -> {
val l = vm.content.mat4Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Mat4Ex(r.id))
}
}
is Route.Mat4Ex -> MathPracticeScreen(vm, 4, r.id)
Route.FyzYears -> FyzYearsScreen(vm)
Route.FyzMap -> LessonListScreen(
vm, vm.tr("fyz_year1"), vm.tr("fyz_sub"),
vm.content.fyz.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyzDone(id), Route.FyzLesson(id))
},
)
is Route.FyzLesson -> {
val l = vm.content.fyzLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.FyzEx(r.id))
}
}
is Route.FyzEx -> FyzQuizScreen(vm, 1, r.id)
Route.Fyz2Map -> LessonListScreen(
vm, vm.tr("fyz_year2"), vm.tr("fyz2_sub"),
vm.content.fyz2.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyz2Done(id), Route.Fyz2Lesson(id))
},
)
is Route.Fyz2Lesson -> {
val l = vm.content.fyz2Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Fyz2Ex(r.id))
}
}
is Route.Fyz2Ex -> FyzQuizScreen(vm, 2, r.id)
Route.Fyz3Map -> LessonListScreen(
vm, vm.tr("fyz_year3"), vm.tr("fyz3_sub"),
vm.content.fyz3.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyz3Done(id), Route.Fyz3Lesson(id))
},
)
is Route.Fyz3Lesson -> {
val l = vm.content.fyz3Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Fyz3Ex(r.id))
}
}
is Route.Fyz3Ex -> FyzQuizScreen(vm, 3, r.id)
Route.Fyz4Map -> LessonListScreen(
vm, vm.tr("fyz_year4"), vm.tr("fyz4_sub"),
vm.content.fyz4.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.fyz4Done(id), Route.Fyz4Lesson(id))
},
)
is Route.Fyz4Lesson -> {
val l = vm.content.fyz4Lesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.Fyz4Ex(r.id))
}
}
is Route.Fyz4Ex -> FyzQuizScreen(vm, 4, r.id)
Route.SciMap -> SciMapScreen(vm)
Route.ChemMap -> LessonListScreen(
vm, vm.tr("Chemie"), vm.tr("chem_sub"),
vm.content.chem.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.chemDone(id), Route.ChemLesson(id))
},
)
is Route.ChemLesson -> {
val l = vm.content.chemLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.ChemEx(r.id))
}
}
is Route.ChemEx -> SciQuizScreen(vm, "chem", r.id)
Route.BioMap -> LessonListScreen(
vm, vm.tr("Biologie"), vm.tr("bio_sub"),
vm.content.bio.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.bioDone(id), Route.BioLesson(id))
},
)
is Route.BioLesson -> {
val l = vm.content.bioLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.BioEx(r.id))
}
}
is Route.BioEx -> SciQuizScreen(vm, "bio", r.id)
Route.CzechMap -> CzechMapScreen(vm)
Route.Mluvnice -> LessonListScreen(
vm, vm.tr("Mluvnice"), vm.tr("mluv_sub"),
vm.content.mluvnice.map { m ->
val n = m.int("id")
LessonRow(m.str("name"), vm.progress.mluvDone(n), Route.MluvEx(n))
},
)
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)
}
}
AnimatedVisibility(
visible = vm.searchOpen,
enter = fadeIn(tween(160)),
exit = fadeOut(tween(120)),
) {
Box(Modifier.fillMaxSize()) { SearchScreen(vm) }
}
}
}

View file

@ -1,24 +1,22 @@
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
import org.maturita.maturita.platform.Platform
import org.maturita.maturita.platform.UpdateState
class AppViewModel(app: Application) : AndroidViewModel(app) {
val content = Content.load(app)
val progress = ProgressStore(app)
val updater = Updater(app)
/** App state shared by every platform; the platform only supplies [Platform]. */
class AppViewModel(val content: Content, val platform: Platform) {
val progress = ProgressStore(platform.prefs, content)
private val updater = platform.updater
var themeId by mutableStateOf(progress.themeId)
private set
@ -30,7 +28,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
var searchQuery by mutableStateOf("")
var tick by mutableStateOf(0)
private set
var update by mutableStateOf(UpdateState())
var update by mutableStateOf(UpdateState(messageArg = platform.commit.take(7)))
private set
var showChangelog by mutableStateOf(shouldShowChangelog())
private set
@ -268,13 +266,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
}
fun dismissChangelog() {
progress.seenCommit = BuildConfig.COMMIT
progress.seenCommit = platform.commit
showChangelog = false
}
private fun shouldShowChangelog(): Boolean {
if (content.changelog.isEmpty()) return false
return !sameBuild(BuildConfig.COMMIT, progress.seenCommit)
return !sameBuild(platform.commit, progress.seenCommit)
}
}

View file

@ -1,6 +1,5 @@
package org.maturita.maturita.data
import android.content.Context
import org.json.JSONObject
class Content(root: JSONObject) {
@ -65,6 +64,8 @@ class Content(root: JSONObject) {
val mluvnice = raw.arr("mluvnice")
val books = raw.arr("books")
val changelog = raw.arr("changelog")
val progressKeys = stringMap(root.optJSONObject("progress")?.optJSONObject("keys"))
val progressLegacy = stringMap(root.optJSONObject("progress")?.optJSONObject("legacy"))
fun germanUnit(id: Int) = german.firstOrNull { it.int("id") == id }
fun netLesson(id: Int) = netLessons.firstOrNull { it.int("id") == id }
@ -135,13 +136,13 @@ class Content(root: JSONObject) {
}
companion object {
fun load(context: Context): Content {
return try {
val text = context.assets.open("content.json").bufferedReader().use { it.readText() }
Content(JSONObject(text))
} catch (_: Exception) {
Content(JSONObject())
}
private fun stringMap(o: JSONObject?): Map<String, String> =
o?.keys()?.asSequence()?.associateWith { o.optString(it) } ?: emptyMap()
fun parse(text: String?): Content = try {
Content(JSONObject(text ?: "{}"))
} catch (_: Exception) {
Content(JSONObject())
}
}
}

View file

@ -1,9 +1,28 @@
package org.maturita.maturita.data
import android.content.Context
import org.maturita.maturita.platform.Prefs
class ProgressStore(context: Context) {
private val prefs = context.getSharedPreferences("maturita", Context.MODE_PRIVATE)
class ProgressStore(private val prefs: Prefs, content: Content) {
private val ids = content.progressKeys
init {
migrate(content.progressLegacy)
}
/** Progress is stored under each lesson's id from content/, so lessons can be
* reordered or renamed without losing it. `address` is the in-app position. */
private fun key(address: String) = "done." + (ids[address] ?: address)
private fun done(address: String) = prefs.getBoolean(key(address), false)
private fun mark(address: String) { prefs.edit().putBoolean(key(address), true).apply() }
private fun migrate(legacy: Map<String, String>) {
if (prefs.getBoolean("progress_ids_v2", false)) return
val edit = prefs.edit()
for ((old, id) in legacy) {
if (prefs.getBoolean(old, false)) edit.putBoolean("done.$id", true)
}
edit.putBoolean("progress_ids_v2", true).apply()
}
var themeId: ThemeId
get() = ThemeId.entries.getOrElse(prefs.getInt("theme", 0)) { ThemeId.Catppuccin }
@ -21,75 +40,75 @@ class ProgressStore(context: Context) {
get() = prefs.getString("seen_commit", "") ?: ""
set(v) { prefs.edit().putString("seen_commit", v).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 germanDone(unit: Int, ex: Int) = done("g.$unit.$ex")
fun markGerman(unit: Int, ex: Int) { mark("g.$unit.$ex") }
fun vocabDone(unit: Int) = done("g.$unit.vocab")
fun markVocab(unit: Int) { mark("g.$unit.vocab") }
fun netDone(id: Int) = prefs.getBoolean("net.$id", false)
fun markNet(id: Int) { prefs.edit().putBoolean("net.$id", true).apply() }
fun netDone(id: Int) = done("net.$id")
fun markNet(id: Int) { mark("net.$id") }
fun hwDone(id: Int) = prefs.getBoolean("hw.$id", false)
fun markHw(id: Int) { prefs.edit().putBoolean("hw.$id", true).apply() }
fun hwDone(id: Int) = done("hw.$id")
fun markHw(id: Int) { mark("hw.$id") }
fun onDone(id: Int) = prefs.getBoolean("on.$id", false)
fun markOn(id: Int) { prefs.edit().putBoolean("on.$id", true).apply() }
fun onDone(id: Int) = done("on.$id")
fun markOn(id: Int) { mark("on.$id") }
fun on2Done(id: Int) = prefs.getBoolean("on2.$id", false)
fun markOn2(id: Int) { prefs.edit().putBoolean("on2.$id", true).apply() }
fun on2Done(id: Int) = done("on2.$id")
fun markOn2(id: Int) { mark("on2.$id") }
fun on3Done(id: Int) = prefs.getBoolean("on3.$id", false)
fun markOn3(id: Int) { prefs.edit().putBoolean("on3.$id", true).apply() }
fun on3Done(id: Int) = done("on3.$id")
fun markOn3(id: Int) { mark("on3.$id") }
fun on4Done(id: Int) = prefs.getBoolean("on4.$id", false)
fun markOn4(id: Int) { prefs.edit().putBoolean("on4.$id", true).apply() }
fun on4Done(id: Int) = done("on4.$id")
fun markOn4(id: Int) { mark("on4.$id") }
fun enDone(year: Int, id: Int) = prefs.getBoolean("en.$year.$id", false)
fun markEn(year: Int, id: Int) { prefs.edit().putBoolean("en.$year.$id", true).apply() }
fun deDone(year: Int, id: Int) = prefs.getBoolean("de.$year.$id", false)
fun markDe(year: Int, id: Int) { prefs.edit().putBoolean("de.$year.$id", true).apply() }
fun courseDone(course: String, id: Int) = prefs.getBoolean("$course.$id", false)
fun markCourse(course: String, id: Int) { prefs.edit().putBoolean("$course.$id", true).apply() }
fun enDone(year: Int, id: Int) = done("en.$year.$id")
fun markEn(year: Int, id: Int) { mark("en.$year.$id") }
fun deDone(year: Int, id: Int) = done("de.$year.$id")
fun markDe(year: Int, id: Int) { mark("de.$year.$id") }
fun courseDone(course: String, id: Int) = done("$course.$id")
fun markCourse(course: String, id: Int) { mark("$course.$id") }
fun litDone(id: Int) = prefs.getBoolean("lit.$id", false)
fun markLit(id: Int) { prefs.edit().putBoolean("lit.$id", true).apply() }
fun lit2Done(id: Int) = prefs.getBoolean("lit2.$id", false)
fun markLit2(id: Int) { prefs.edit().putBoolean("lit2.$id", true).apply() }
fun lit3Done(id: Int) = prefs.getBoolean("lit3.$id", false)
fun markLit3(id: Int) { prefs.edit().putBoolean("lit3.$id", true).apply() }
fun lit4Done(id: Int) = prefs.getBoolean("lit4.$id", false)
fun markLit4(id: Int) { prefs.edit().putBoolean("lit4.$id", true).apply() }
fun litDone(id: Int) = done("lit.$id")
fun markLit(id: Int) { mark("lit.$id") }
fun lit2Done(id: Int) = done("lit2.$id")
fun markLit2(id: Int) { mark("lit2.$id") }
fun lit3Done(id: Int) = done("lit3.$id")
fun markLit3(id: Int) { mark("lit3.$id") }
fun lit4Done(id: Int) = done("lit4.$id")
fun markLit4(id: Int) { mark("lit4.$id") }
fun chemDone(id: Int) = prefs.getBoolean("chem.$id", false)
fun markChem(id: Int) { prefs.edit().putBoolean("chem.$id", true).apply() }
fun bioDone(id: Int) = prefs.getBoolean("bio.$id", false)
fun markBio(id: Int) { prefs.edit().putBoolean("bio.$id", true).apply() }
fun fyzDone(id: Int) = prefs.getBoolean("fyz.$id", false)
fun markFyz(id: Int) { prefs.edit().putBoolean("fyz.$id", true).apply() }
fun fyz2Done(id: Int) = prefs.getBoolean("fyz2.$id", false)
fun markFyz2(id: Int) { prefs.edit().putBoolean("fyz2.$id", true).apply() }
fun fyz3Done(id: Int) = prefs.getBoolean("fyz3.$id", false)
fun markFyz3(id: Int) { prefs.edit().putBoolean("fyz3.$id", true).apply() }
fun fyz4Done(id: Int) = prefs.getBoolean("fyz4.$id", false)
fun markFyz4(id: Int) { prefs.edit().putBoolean("fyz4.$id", true).apply() }
fun mat0Done(id: Int) = prefs.getBoolean("mat0.$id", false)
fun markMat0(id: Int) { prefs.edit().putBoolean("mat0.$id", true).apply() }
fun matDone(id: Int) = prefs.getBoolean("mat.$id", false)
fun markMat(id: Int) { prefs.edit().putBoolean("mat.$id", true).apply() }
fun mat2Done(id: Int) = prefs.getBoolean("mat2.$id", false)
fun markMat2(id: Int) { prefs.edit().putBoolean("mat2.$id", true).apply() }
fun mat3Done(id: Int) = prefs.getBoolean("mat3.$id", false)
fun markMat3(id: Int) { prefs.edit().putBoolean("mat3.$id", true).apply() }
fun mat4Done(id: Int) = prefs.getBoolean("mat4.$id", false)
fun markMat4(id: Int) { prefs.edit().putBoolean("mat4.$id", true).apply() }
fun chemDone(id: Int) = done("chem.$id")
fun markChem(id: Int) { mark("chem.$id") }
fun bioDone(id: Int) = done("bio.$id")
fun markBio(id: Int) { mark("bio.$id") }
fun fyzDone(id: Int) = done("fyz.$id")
fun markFyz(id: Int) { mark("fyz.$id") }
fun fyz2Done(id: Int) = done("fyz2.$id")
fun markFyz2(id: Int) { mark("fyz2.$id") }
fun fyz3Done(id: Int) = done("fyz3.$id")
fun markFyz3(id: Int) { mark("fyz3.$id") }
fun fyz4Done(id: Int) = done("fyz4.$id")
fun markFyz4(id: Int) { mark("fyz4.$id") }
fun mat0Done(id: Int) = done("mat0.$id")
fun markMat0(id: Int) { mark("mat0.$id") }
fun matDone(id: Int) = done("mat.$id")
fun markMat(id: Int) { mark("mat.$id") }
fun mat2Done(id: Int) = done("mat2.$id")
fun markMat2(id: Int) { mark("mat2.$id") }
fun mat3Done(id: Int) = done("mat3.$id")
fun markMat3(id: Int) { mark("mat3.$id") }
fun mat4Done(id: Int) = done("mat4.$id")
fun markMat4(id: Int) { mark("mat4.$id") }
fun mluvDone(n: Int) = prefs.getBoolean("mluv.$n", false)
fun markMluv(n: Int) { prefs.edit().putBoolean("mluv.$n", true).apply() }
fun mluvDone(n: Int) = done("mluv.$n")
fun markMluv(n: Int) { mark("mluv.$n") }
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() }
fun bookQuiz(id: String) = done("book.$id.quiz")
fun markBookQuiz(id: String) { mark("book.$id.quiz") }
fun bookPlot(id: String) = done("book.$id.plot")
fun markBookPlot(id: String) { mark("book.$id.plot") }
}
data class ProgressSum(var doneEx: Int = 0, var totalEx: Int = 0, var doneUnits: Int = 0, var openUnits: Int = 0)

View file

@ -0,0 +1,69 @@
package org.maturita.maturita.platform
import java.util.Locale
/**
* Everything the shared app needs from the system it runs on. Android and the
* desktop (Linux, Windows) each provide one; the screens never touch platform
* APIs directly.
*/
interface Platform {
/** Source commit baked into the build, or "dev" for local builds. */
val commit: String
val versionName: String
/** Text key of the welcome card blurb, which names the device. */
val welcomeKey: String
val prefs: Prefs
val updater: AppUpdater
/** A speech engine for listening exercises, or null when there is none. */
fun speaker(): Speaker?
}
/** The subset of Android's SharedPreferences the app uses. */
interface Prefs {
fun getBoolean(key: String, fallback: Boolean): Boolean
fun getInt(key: String, fallback: Int): Int
fun getString(key: String, fallback: String?): String?
fun edit(): Editor
interface Editor {
fun putBoolean(key: String, value: Boolean): Editor
fun putInt(key: String, value: Int): Editor
fun putString(key: String, value: String): Editor
fun apply()
}
}
data class UpdateState(
val status: String = "idle",
val remote: String? = null,
val messageKey: String = "update_current",
val messageArg: String? = null,
val canInstall: Boolean = false,
)
/** Callbacks always arrive on the UI thread. */
interface AppUpdater {
fun check(interactive: Boolean, cb: (UpdateState) -> Unit)
fun install(cb: (UpdateState) -> Unit)
}
interface Speaker {
/**
* Reads [text] aloud. [onRange] gets the offset of the word being spoken
* where the engine reports it; [onEnd] gets false when speaking failed.
* Both run on the UI thread.
*/
fun speak(
text: String,
locale: Locale,
rate: Float,
volume: Float,
onRange: (Int) -> Unit,
onEnd: (Boolean) -> Unit,
)
fun stop()
fun close()
}

View file

@ -37,16 +37,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalContext
import java.util.Locale
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -1423,18 +1417,9 @@ fun EngExScreen(
var revealed by remember(year, id) { mutableStateOf(false) }
var showModel by remember(year, id) { mutableStateOf(false) }
var fb by remember(year, id) { mutableStateOf("" to "") }
val context = LocalContext.current
var tts by remember { mutableStateOf<TextToSpeech?>(null) }
DisposableEffect(context) {
var engine: TextToSpeech? = null
engine = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) engine?.language = locale
}
tts = engine
onDispose {
engine?.stop()
engine?.shutdown()
}
val tts = remember { vm.platform.speaker() }
DisposableEffect(tts) {
onDispose { tts?.close() }
}
fun picksOk(questions: List<J>, picks: List<Int>) =
@ -1490,32 +1475,18 @@ fun EngExScreen(
val start = index.coerceIn(0, listening.length)
val id = "en-${System.nanoTime()}"
utterance = id
engine.language = locale
engine.setSpeechRate(speed)
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onRangeStart(utteranceId: String?, rangeStart: Int, rangeEnd: Int, frame: Int) {
if (utteranceId == id) cursor = start + rangeStart
}
override fun onDone(utteranceId: String?) {
if (utteranceId != utterance) return
Handler(Looper.getMainLooper()).post {
val rest = listening.substring(start).ifEmpty { listening }
engine.speak(
rest, locale, speed, volume.coerceIn(0f, 1f),
onRange = { offset -> if (utterance == id) cursor = start + offset },
onEnd = { ok ->
if (utterance == id) {
phase = 0
cursor = 0
if (ok) cursor = 0
showScript = true
}
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
if (utteranceId != utterance) return
Handler(Looper.getMainLooper()).post { showScript = true; phase = 0 }
}
})
val params = Bundle().apply {
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume.coerceIn(0f, 1f))
}
val rest = listening.substring(start).ifEmpty { listening }
engine.speak(rest, TextToSpeech.QUEUE_FLUSH, params, id)
},
)
phase = 1
}
Text(vm.tr("en_sec_listen"), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))

View file

@ -74,7 +74,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.maturita.maturita.AppViewModel
import org.maturita.maturita.BuildConfig
import org.maturita.maturita.Route
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.data.J
@ -110,7 +109,7 @@ fun HomeScreen(vm: AppViewModel) {
)
Spacer(Modifier.height(8.dp))
Text(
vm.tr("welcome_body_android"),
vm.tr(vm.platform.welcomeKey),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
)
@ -433,7 +432,7 @@ fun SettingsScreen(vm: AppViewModel) {
item {
ListItem(
headlineContent = { Text("Build") },
supportingContent = { Text("${BuildConfig.VERSION_NAME} · ${BuildConfig.COMMIT.take(7)}") },
supportingContent = { Text("${vm.platform.versionName} · ${vm.platform.commit.take(7)}") },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.padding(top = 8.dp),
)