mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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:
parent
b324496334
commit
c3bc32113b
964 changed files with 57285 additions and 1156 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
103
android/desktop/build.gradle.kts
Normal file
103
android/desktop/build.gradle.kts
Normal 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
2
android/desktop/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-dontwarn org.slf4j.**
|
||||
-dontnote **
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -16,3 +16,4 @@ dependencyResolutionManagement {
|
|||
|
||||
rootProject.name = "maturita"
|
||||
include(":app")
|
||||
include(":desktop")
|
||||
|
|
|
|||
580
android/shared/src/org/maturita/maturita/App.kt
Normal file
580
android/shared/src/org/maturita/maturita/App.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue