Add a Jetpack Compose Android app that plays the same maturita content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-18 10:54:49 +02:00
commit 9d8b853081
50 changed files with 4904 additions and 5 deletions

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="maturita.c"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Maturita"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,120 @@
package org.maturita.maturita
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.data.Content
import org.maturita.maturita.data.ProgressStore
import org.maturita.maturita.data.ThemeId
import org.maturita.maturita.data.UiLang
import org.maturita.maturita.data.themePalette
import org.maturita.maturita.update.UpdateState
import org.maturita.maturita.update.Updater
class AppViewModel(app: Application) : AndroidViewModel(app) {
val content = Content.load(app)
val progress = ProgressStore(app)
val updater = Updater(app)
var themeId by mutableStateOf(progress.themeId)
private set
var mode by mutableStateOf(progress.mode)
private set
var lang by mutableStateOf(progress.lang)
private set
var settingsOpen by mutableStateOf(false)
var searchOpen by mutableStateOf(false)
var searchQuery by mutableStateOf("")
var tick by mutableStateOf(0)
private set
var update by mutableStateOf(UpdateState())
private set
private val stack = mutableStateListOf<Route>(Route.Welcome)
val route: Route get() = stack.last()
val canGoBack: Boolean get() = stack.size > 1
val palette get() = themePalette(themeId, mode)
fun tr(key: String?) = content.tr(key, lang)
fun fmt(key: String, vararg args: Any) = content.fmt(key, lang, *args)
fun go(r: Route) {
if (stack.last() == r) return
stack.add(r)
searchOpen = false
}
fun goPage(page: String?) {
val r = page?.let { routeFromPage(it) } ?: return
go(r)
}
fun back() {
if (stack.size > 1) stack.removeAt(stack.lastIndex)
}
fun setTheme(id: ThemeId) {
themeId = id
progress.themeId = id
}
fun applyMode(m: ColorMode) {
mode = m
progress.mode = m
}
fun applyLang(l: UiLang) {
lang = l
progress.lang = l
}
fun refresh() { tick++ }
fun markGerman(unit: Int, ex: Int) {
progress.markGerman(unit, ex)
refresh()
}
fun markVocab(unit: Int) {
progress.markVocab(unit)
refresh()
}
fun markNet(id: Int) {
progress.markNet(id)
refresh()
}
fun markHw(id: Int) {
progress.markHw(id)
refresh()
}
fun markMluv(n: Int) {
progress.markMluv(n)
refresh()
}
fun markBookQuiz(id: String) {
progress.markBookQuiz(id)
refresh()
}
fun markBookPlot(id: String) {
progress.markBookPlot(id)
refresh()
}
fun checkUpdate(interactive: Boolean) {
updater.check(interactive) { update = it }
}
fun installUpdate() {
updater.install { update = it }
}
}

View file

@ -0,0 +1,195 @@
package org.maturita.maturita
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.maturita.maturita.ui.BookListScreen
import org.maturita.maturita.ui.BookScreen
import org.maturita.maturita.ui.CzechMapScreen
import org.maturita.maturita.ui.GermanExercise
import org.maturita.maturita.ui.HwQuizScreen
import org.maturita.maturita.ui.IconHit
import org.maturita.maturita.ui.LessonMapScreen
import org.maturita.maturita.ui.LitQuizScreen
import org.maturita.maturita.ui.MapNode
import org.maturita.maturita.ui.MluvExercise
import org.maturita.maturita.ui.NetQuizScreen
import org.maturita.maturita.ui.NetYearsScreen
import org.maturita.maturita.ui.PlotScreen
import org.maturita.maturita.ui.RoadmapScreen
import org.maturita.maturita.ui.SearchIcon
import org.maturita.maturita.ui.SearchOverlay
import org.maturita.maturita.ui.SettingsIcon
import org.maturita.maturita.ui.SettingsSheet
import org.maturita.maturita.ui.SlidesScreen
import org.maturita.maturita.ui.StatsIcon
import org.maturita.maturita.ui.StatsScreen
import org.maturita.maturita.ui.SubjectsScreen
import org.maturita.maturita.ui.UnitMapScreen
import org.maturita.maturita.ui.VocabExercise
import org.maturita.maturita.ui.WelcomeScreen
class MainActivity : ComponentActivity() {
private val vm: AppViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { MaturitaApp(vm) }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MaturitaApp(vm: AppViewModel) {
val p = vm.palette
LaunchedEffect(Unit) { vm.checkUpdate(false) }
BackHandler(vm.canGoBack || vm.searchOpen || vm.settingsOpen) {
when {
vm.searchOpen -> vm.searchOpen = false
vm.settingsOpen -> vm.settingsOpen = false
else -> vm.back()
}
}
Box(
Modifier
.fillMaxSize()
.background(p.base)
.statusBarsPadding()
.navigationBarsPadding(),
) {
Column(Modifier.fillMaxSize()) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("maturita.c", color = p.text, fontWeight = FontWeight.Bold, fontSize = 15.sp, modifier = Modifier.weight(1f))
IconHit({ vm.searchOpen = true }) { SearchIcon(p.text) }
IconHit({ vm.go(Route.Stats) }) { StatsIcon(p.text) }
IconHit({ vm.settingsOpen = true }) { SettingsIcon(p.text) }
}
if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(14.dp))
.background(p.surface1)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())),
color = p.text,
fontSize = 13.sp,
modifier = Modifier.weight(1f),
)
if (vm.update.canInstall) {
org.maturita.maturita.ui.PillButton(vm.tr("update_install"), p) { vm.installUpdate() }
}
}
}
Box(Modifier.weight(1f)) {
when (val r = vm.route) {
Route.Welcome -> WelcomeScreen(vm)
Route.Subjects -> SubjectsScreen(vm)
Route.Stats -> StatsScreen(vm)
Route.Roadmap -> RoadmapScreen(vm)
is Route.UnitMap -> UnitMapScreen(vm, r.unitId)
is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex)
is Route.Vocab -> VocabExercise(vm, r.unitId)
Route.NetYears -> NetYearsScreen(vm)
Route.NetMap -> LessonMapScreen(
vm, vm.tr("net_year1"), vm.tr("net_sub"),
vm.content.netLessons.map { l ->
val id = l.int("id")
MapNode(
"n$id", vm.tr(l.str("titleKey")), false,
vm.progress.netDone(id), !vm.progress.netDone(id),
) { vm.go(Route.NetLesson(id)) }
},
vm.content.netLessons.count { vm.progress.netDone(it.int("id")) }.toFloat(),
)
is Route.NetLesson -> {
val l = vm.content.netLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.go(Route.NetEx(r.id))
}
}
is Route.NetEx -> NetQuizScreen(vm, r.id)
Route.HwMap -> LessonMapScreen(
vm, vm.tr("Technické vybavení"), vm.tr("hw_sub"),
vm.content.hw.map { l ->
val id = l.int("id")
MapNode(
"h$id", vm.tr(l.str("titleKey")), false,
vm.progress.hwDone(id), !vm.progress.hwDone(id),
) { vm.go(Route.HwLesson(id)) }
},
vm.content.hw.count { vm.progress.hwDone(it.int("id")) }.toFloat(),
)
is Route.HwLesson -> {
val l = vm.content.hwLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.go(Route.HwEx(r.id))
}
}
is Route.HwEx -> HwQuizScreen(vm, r.id)
Route.CzechMap -> CzechMapScreen(vm)
Route.Mluvnice -> LessonMapScreen(
vm, vm.tr("Mluvnice"), "Cvičení z mluvnice – styl maturita / přijímačky z ČJL",
vm.content.mluvnice.map { m ->
val n = m.int("id")
MapNode(
"m$n", m.str("name"), false,
vm.progress.mluvDone(n), !vm.progress.mluvDone(n),
) { vm.go(Route.MluvEx(n)) }
},
vm.content.mluvnice.count { vm.progress.mluvDone(it.int("id")) }.toFloat(),
)
is Route.MluvEx -> MluvExercise(vm, r.n)
Route.ReadingList -> BookListScreen(vm)
is Route.Book -> BookScreen(vm, r.id)
is Route.BookQuiz -> LitQuizScreen(vm, r.id)
is Route.BookPlot -> PlotScreen(vm, r.id)
}
}
}
if (vm.searchOpen) SearchOverlay(vm)
if (vm.settingsOpen) {
ModalBottomSheet(
onDismissRequest = { vm.settingsOpen = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = p.base,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
) { SettingsSheet(vm) }
}
}
}

View file

@ -0,0 +1,71 @@
package org.maturita.maturita
sealed class Route(val page: String) {
data object Welcome : Route("welcome")
data object Subjects : Route("subjects")
data object Stats : Route("stats")
data object Roadmap : Route("roadmap")
data class UnitMap(val unitId: Int) : Route("unit${unitId + 1}")
data class GermanEx(val unitId: Int, val ex: Int) : Route("u${unitId + 1}e$ex")
data class Vocab(val unitId: Int) : Route("u${unitId + 1}vocab")
data object NetYears : Route("netyears")
data object NetMap : Route("netmap")
data class NetLesson(val id: Int) : Route("netunit$id")
data class NetEx(val id: Int) : Route("netex$id")
data object HwMap : Route("hwmap")
data class HwLesson(val id: Int) : Route("hwunit$id")
data class HwEx(val id: Int) : Route("hwex$id")
data object CzechMap : Route("czechmap")
data object Mluvnice : Route("mluvnice")
data class MluvEx(val n: Int) : Route("mluve$n")
data object ReadingList : Route("readinglist")
data class Book(val id: String) : Route(if (id == "1984") "cetba1984" else "cetbaFuks")
data class BookQuiz(val id: String) : Route(if (id == "1984") "cetba1984quiz" else "cetbaFuksQuiz")
data class BookPlot(val id: String) : Route(if (id == "1984") "cetba1984dej" else "cetbaFuksDej")
}
fun routeFromPage(page: String): Route? = when (page) {
"welcome" -> Route.Welcome
"subjects" -> Route.Subjects
"stats" -> Route.Stats
"roadmap" -> Route.Roadmap
"netyears" -> Route.NetYears
"netmap" -> Route.NetMap
"hwmap" -> Route.HwMap
"czechmap" -> Route.CzechMap
"mluvnice" -> Route.Mluvnice
"readinglist" -> Route.ReadingList
"cetba1984" -> Route.Book("1984")
"cetba1984quiz" -> Route.BookQuiz("1984")
"cetba1984dej" -> Route.BookPlot("1984")
"cetbaFuks" -> Route.Book("fuks")
"cetbaFuksQuiz" -> Route.BookQuiz("fuks")
"cetbaFuksDej" -> Route.BookPlot("fuks")
else -> {
Regex("""unit(\d+)""").matchEntire(page)?.let {
return Route.UnitMap(it.groupValues[1].toInt() - 1)
}
Regex("""u(\d+)e(\d+)""").matchEntire(page)?.let {
return Route.GermanEx(it.groupValues[1].toInt() - 1, it.groupValues[2].toInt())
}
Regex("""u(\d+)vocab""").matchEntire(page)?.let {
return Route.Vocab(it.groupValues[1].toInt() - 1)
}
Regex("""netunit(\d+)""").matchEntire(page)?.let {
return Route.NetLesson(it.groupValues[1].toInt())
}
Regex("""netex(\d+)""").matchEntire(page)?.let {
return Route.NetEx(it.groupValues[1].toInt())
}
Regex("""hwunit(\d+)""").matchEntire(page)?.let {
return Route.HwLesson(it.groupValues[1].toInt())
}
Regex("""hwex(\d+)""").matchEntire(page)?.let {
return Route.HwEx(it.groupValues[1].toInt())
}
Regex("""mluve(\d+)""").matchEntire(page)?.let {
return Route.MluvEx(it.groupValues[1].toInt())
}
null
}
}

View file

@ -0,0 +1,65 @@
package org.maturita.maturita.data
import java.text.Normalizer
fun normalizeAnswer(input: String?): String {
val src = (input ?: "").lowercase()
val pre = StringBuilder()
src.codePoints().forEach { ch ->
when (ch) {
0x00e4 -> pre.append("ae")
0x00f6 -> pre.append("oe")
0x00fc -> pre.append("ue")
0x00df -> pre.append("ss")
else -> pre.appendCodePoint(ch)
}
}
val decomp = Normalizer.normalize(pre.toString(), Normalizer.Form.NFD)
val out = StringBuilder()
var prevSpace = false
decomp.codePoints().forEach { ch ->
val type = Character.getType(ch)
if (type == Character.NON_SPACING_MARK.toInt() ||
type == Character.COMBINING_SPACING_MARK.toInt() ||
type == Character.ENCLOSING_MARK.toInt()
) {
return@forEach
}
if (Character.isWhitespace(ch)) {
if (out.isNotEmpty() && !prevSpace) out.append(' ')
prevSpace = true
return@forEach
}
if (!Character.isLetterOrDigit(ch)) return@forEach
prevSpace = false
out.appendCodePoint(ch)
}
return out.toString().trimEnd()
}
fun answerAccepts(selNorm: String, ans: String?): Boolean {
if (ans.isNullOrEmpty()) return false
return ans.split('|').any { normalizeAnswer(it) == selNorm }
}
fun answerIncomplete(selNorm: String, ans: String?): Boolean {
if (selNorm.isEmpty() || ans.isNullOrEmpty()) return false
for (part in ans.split('|')) {
val a = normalizeAnswer(part)
if (a.startsWith("$selNorm ") && a.length > selNorm.length + 1) return true
}
return false
}
fun netTxtEq(a: String?, b: String?): Boolean {
if (a == null || b == null) return a == b
val x = a.filterNot { it.isWhitespace() }.lowercase()
val y = b.filterNot { it.isWhitespace() }.lowercase()
return x == y
}
fun hasUmlaut(s: String?): Boolean {
if (s == null) return false
return s.any { it == 'ä' || it == 'ö' || it == 'ü' || it == 'ß' ||
it == 'Ä' || it == 'Ö' || it == 'Ü' }
}

View file

@ -0,0 +1,66 @@
package org.maturita.maturita.data
import android.content.Context
import org.json.JSONObject
class Content(root: JSONObject) {
val raw = J(root)
val i18n: Map<String, Pair<String?, String?>> = run {
val obj = root.optJSONObject("i18n") ?: JSONObject()
obj.keys().asSequence().associateWith { key ->
val e = obj.optJSONObject(key)
val cs = e?.optString("cs")?.takeIf { it.isNotEmpty() && it != "null" }
val en = e?.optString("en")?.takeIf { it.isNotEmpty() && it != "null" }
cs to en
}
}
val subjects = raw.arr("subjects")
val german = raw.arr("german")
val net = raw.obj("net")!!
val netLessons = net.arr("lessons")
val netTasks = net.arr("tasks")
val netAnswers: List<List<J>> = run {
val a = net.o.optJSONArray("answers") ?: return@run emptyList()
(0 until a.length()).map { i ->
val block = a.optJSONArray(i) ?: org.json.JSONArray()
(0 until block.length()).mapNotNull { j ->
block.optJSONObject(j)?.let { J(it) }
}
}
}
val hw = raw.arr("hw")
val mluvnice = raw.arr("mluvnice")
val books = raw.arr("books")
fun germanUnit(id: Int) = german.firstOrNull { it.int("id") == id }
fun netLesson(id: Int) = netLessons.firstOrNull { it.int("id") == id }
fun hwLesson(id: Int) = hw.firstOrNull { it.int("id") == id }
fun mluv(n: Int) = mluvnice.firstOrNull { it.int("id") == n }
fun book(id: String) = books.firstOrNull { it.str("id") == id }
fun tr(key: String?, lang: UiLang): String {
if (key.isNullOrEmpty()) return ""
val hit = i18n[key]
return if (lang == UiLang.En) {
hit?.second ?: key
} else {
hit?.first ?: key
}
}
fun fmt(key: String, lang: UiLang, vararg args: Any): String {
val template = tr(key, lang)
return try {
String.format(template, *args)
} catch (_: Exception) {
template
}
}
companion object {
fun load(context: Context): Content {
val text = context.assets.open("content.json").bufferedReader().use { it.readText() }
return Content(JSONObject(text))
}
}
}

View file

@ -0,0 +1,52 @@
package org.maturita.maturita.data
import org.json.JSONArray
import org.json.JSONObject
class J(val o: JSONObject) {
fun has(key: String) = o.has(key) && !o.isNull(key)
fun str(key: String, fallback: String = "") =
if (!has(key)) fallback else o.optString(key, fallback)
fun strOrNull(key: String): String? =
if (!has(key)) null else o.optString(key).takeIf { it.isNotEmpty() && it != "null" }
fun bool(key: String, fallback: Boolean = false) = o.optBoolean(key, fallback)
fun int(key: String, fallback: Int = 0) = o.optInt(key, fallback)
fun obj(key: String): J? = o.optJSONObject(key)?.let { J(it) }
fun exercise(n: Int): J? = o.optJSONObject("exercises")?.optJSONObject(n.toString())?.let { J(it) }
fun arr(key: String): List<J> {
val a = o.optJSONArray(key) ?: return emptyList()
return (0 until a.length()).mapNotNull { i -> a.optJSONObject(i)?.let { J(it) } }
}
fun strs(key: String): List<String> {
val a = o.optJSONArray(key) ?: return emptyList()
return (0 until a.length()).mapNotNull { i ->
if (a.isNull(i)) null else a.optString(i).takeIf { it.isNotEmpty() }
}
}
fun strsOrEmpty(key: String): List<String?> {
val a = o.optJSONArray(key) ?: return emptyList()
return (0 until a.length()).map { i ->
if (a.isNull(i)) null else a.optString(i)
}
}
fun intRows(key: String): List<List<String>> {
val a = o.optJSONArray(key) ?: return emptyList()
return (0 until a.length()).map { i ->
val row = a.optJSONArray(i) ?: JSONArray()
(0 until row.length()).map { j -> row.optString(j) }
}
}
fun answerList(): List<String> {
val a = o.optJSONArray("answers")
if (a != null) {
return (0 until a.length()).mapNotNull { i ->
if (a.isNull(i)) null else a.optString(i).takeIf { it.isNotEmpty() }
}
}
return listOfNotNull(strOrNull("answers"))
}
}
fun JSONArray.objects(): List<J> =
(0 until length()).mapNotNull { i -> optJSONObject(i)?.let { J(it) } }

View file

@ -0,0 +1,75 @@
package org.maturita.maturita.data
import android.content.Context
class ProgressStore(context: Context) {
private val prefs = context.getSharedPreferences("maturita", Context.MODE_PRIVATE)
var themeId: ThemeId
get() = ThemeId.entries.getOrElse(prefs.getInt("theme", 0)) { ThemeId.Catppuccin }
set(v) { prefs.edit().putInt("theme", v.ordinal).apply() }
var mode: ColorMode
get() = if (prefs.getString("mode", "light") == "dark") ColorMode.Dark else ColorMode.Light
set(v) { prefs.edit().putString("mode", if (v == ColorMode.Light) "light" else "dark").apply() }
var lang: UiLang
get() = if (prefs.getString("lang", "cs") == "en") UiLang.En else UiLang.Cs
set(v) { prefs.edit().putString("lang", if (v == UiLang.En) "en" else "cs").apply() }
fun germanDone(unit: Int, ex: Int) = prefs.getBoolean("g.$unit.$ex", false)
fun markGerman(unit: Int, ex: Int) { prefs.edit().putBoolean("g.$unit.$ex", true).apply() }
fun vocabDone(unit: Int) = prefs.getBoolean("g.$unit.vocab", false)
fun markVocab(unit: Int) { prefs.edit().putBoolean("g.$unit.vocab", true).apply() }
fun netDone(id: Int) = prefs.getBoolean("net.$id", false)
fun markNet(id: Int) { prefs.edit().putBoolean("net.$id", true).apply() }
fun hwDone(id: Int) = prefs.getBoolean("hw.$id", false)
fun markHw(id: Int) { prefs.edit().putBoolean("hw.$id", true).apply() }
fun mluvDone(n: Int) = prefs.getBoolean("mluv.$n", false)
fun markMluv(n: Int) { prefs.edit().putBoolean("mluv.$n", true).apply() }
fun bookQuiz(id: String) = prefs.getBoolean("book.$id.quiz", false)
fun markBookQuiz(id: String) { prefs.edit().putBoolean("book.$id.quiz", true).apply() }
fun bookPlot(id: String) = prefs.getBoolean("book.$id.plot", false)
fun markBookPlot(id: String) { prefs.edit().putBoolean("book.$id.plot", true).apply() }
}
data class ProgressSum(var doneEx: Int = 0, var totalEx: Int = 0, var doneUnits: Int = 0, var openUnits: Int = 0)
fun summarize(content: Content, p: ProgressStore): ProgressSum {
val sum = ProgressSum()
content.german.filter { it.bool("unlocked") }.forEach { u ->
val id = u.int("id")
val names = u.strs("names")
sum.totalEx += names.size
sum.openUnits += 1
val done = names.indices.count { p.germanDone(id, it + 1) }
sum.doneEx += done
if (names.isNotEmpty() && done == names.size) sum.doneUnits += 1
}
content.netLessons.forEach { l ->
sum.totalEx += 1
sum.openUnits += 1
if (p.netDone(l.int("id"))) {
sum.doneEx += 1
sum.doneUnits += 1
}
}
content.hw.forEach { l ->
sum.totalEx += 1
sum.openUnits += 1
if (p.hwDone(l.int("id"))) {
sum.doneEx += 1
sum.doneUnits += 1
}
}
sum.totalEx += content.mluvnice.size
sum.openUnits += 1
val md = content.mluvnice.count { p.mluvDone(it.int("id")) }
sum.doneEx += md
if (md == content.mluvnice.size && content.mluvnice.isNotEmpty()) sum.doneUnits += 1
return sum
}

View file

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

View file

@ -0,0 +1,185 @@
package org.maturita.maturita.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.maturita.maturita.AppViewModel
import org.maturita.maturita.data.Palette
private val Pill = RoundedCornerShape(999.dp)
private val Card = RoundedCornerShape(20.dp)
@Composable
fun PrimaryButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(
modifier
.clip(Pill)
.background(palette.accent)
.clickable(onClick = onClick)
.padding(horizontal = 22.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(label, color = palette.onAccent, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
@Composable
fun PillButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(
modifier
.clip(Pill)
.background(palette.text.copy(alpha = 0.06f))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(label, color = palette.text, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
}
}
@Composable
fun IconHit(onClick: () -> Unit, content: @Composable () -> Unit) {
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) { content() }
}
@Composable
fun PageTop(vm: AppViewModel, back: () -> Unit, title: String, subtitle: String? = null) {
val p = vm.palette
Box(Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 14.dp)) {
Box(Modifier.align(Alignment.CenterStart)) {
IconHit(back) { BackIcon(p.text) }
}
Column(Modifier.align(Alignment.Center).padding(horizontal = 48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, color = p.text, fontSize = 24.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
if (!subtitle.isNullOrBlank()) {
Text(subtitle, color = p.subtext, fontSize = 14.sp, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 4.dp))
}
}
}
}
@Composable
fun CardBox(palette: Palette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier
.clip(Card)
.background(palette.mantle)
.border(1.dp, palette.text.copy(alpha = 0.07f), Card)
.padding(20.dp),
) { content() }
}
@Composable
fun FeedbackLine(text: String, kind: String, palette: Palette) {
if (text.isEmpty()) return
val color = when (kind) {
"ok" -> palette.success
"warn" -> palette.warning
else -> palette.error
}
Text(text, color = color, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
}
@Composable
fun Meaning(text: String, palette: Palette, visible: Boolean) {
if (!visible || text.isEmpty()) return
Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp))
}
@Composable
fun WordField(
value: String,
onValue: (String) -> Unit,
palette: Palette,
placeholder: String,
mark: Boolean? = null,
modifier: Modifier = Modifier,
) {
val border = when (mark) {
true -> palette.success
false -> palette.error
null -> palette.text.copy(alpha = 0.12f)
}
Box(
modifier
.clip(RoundedCornerShape(14.dp))
.background(palette.surface0)
.border(1.dp, border, RoundedCornerShape(14.dp))
.padding(horizontal = 14.dp, vertical = 12.dp)
.fillMaxWidth(),
) {
if (value.isEmpty()) Text(placeholder, color = palette.overlay, fontSize = 15.sp)
BasicTextField(
value = value,
onValueChange = onValue,
textStyle = TextStyle(color = palette.text, fontSize = 15.sp),
cursorBrush = SolidColor(palette.accent),
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun Segmented(palette: Palette, content: @Composable RowScope.() -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clip(Pill)
.background(palette.surface1)
.padding(3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
content = content,
)
}
@Composable
fun SegChip(label: String, selected: Boolean, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(
modifier
.clip(Pill)
.background(if (selected) palette.mantle else Color.Transparent)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(label, color = palette.text, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
}
}
@Composable
fun Hint(text: String, palette: Palette) {
Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp))
}
@Composable
fun Prompt(text: String, palette: Palette) {
Text(text, color = palette.text, fontSize = 16.sp, fontWeight = FontWeight.Medium)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,241 @@
package org.maturita.maturita.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.maturita.maturita.data.Palette
@Composable
fun DrawnIcon(iconSize: Dp, modifier: Modifier = Modifier, onDraw: androidx.compose.ui.graphics.drawscope.DrawScope.() -> Unit) {
Canvas(modifier.size(iconSize)) { onDraw() }
}
@Composable
fun BackIcon(color: Color, iconSize: Dp = 16.dp) {
DrawnIcon(iconSize) {
val p = Path().apply {
moveTo(size.width * 0.68f, size.height * 0.18f)
lineTo(size.width * 0.28f, size.height * 0.5f)
lineTo(size.width * 0.68f, size.height * 0.82f)
}
drawPath(p, color, style = Stroke(width = size.minDimension * 0.16f, cap = StrokeCap.Round))
}
}
@Composable
fun SearchIcon(color: Color, iconSize: Dp = 18.dp) {
DrawnIcon(iconSize) {
val r = size.minDimension * 0.28f
drawCircle(color, r, Offset(size.width * 0.42f, size.height * 0.42f), style = Stroke(size.minDimension * 0.12f))
drawLine(color, Offset(size.width * 0.62f, size.height * 0.62f), Offset(size.width * 0.82f, size.height * 0.82f), size.minDimension * 0.12f, StrokeCap.Round)
}
}
@Composable
fun StatsIcon(color: Color, iconSize: Dp = 18.dp) {
DrawnIcon(iconSize) {
val w = size.minDimension * 0.16f
drawRoundRect(color, Offset(size.width * 0.18f, size.height * 0.48f), Size(w, size.height * 0.36f), androidx.compose.ui.geometry.CornerRadius(w))
drawRoundRect(color, Offset(size.width * 0.42f, size.height * 0.22f), Size(w, size.height * 0.62f), androidx.compose.ui.geometry.CornerRadius(w))
drawRoundRect(color, Offset(size.width * 0.66f, size.height * 0.36f), Size(w, size.height * 0.48f), androidx.compose.ui.geometry.CornerRadius(w))
}
}
@Composable
fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) {
DrawnIcon(iconSize) {
val c = Offset(size.width / 2, size.height / 2)
drawCircle(color, size.minDimension * 0.16f, c, style = Stroke(size.minDimension * 0.1f))
for (i in 0 until 6) {
val a = i * Math.PI / 3.0
val x = (c.x + kotlin.math.cos(a) * size.minDimension * 0.34).toFloat()
val y = (c.y + kotlin.math.sin(a) * size.minDimension * 0.34).toFloat()
drawCircle(color, size.minDimension * 0.07f, Offset(x, y))
}
}
}
@Composable
fun LockIcon(color: Color, iconSize: Dp = 18.dp) {
DrawnIcon(iconSize) {
val body = Path().apply {
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
size.width * 0.28f, size.height * 0.46f,
size.width * 0.72f, size.height * 0.82f,
androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.08f),
),
)
}
drawPath(body, color)
drawArc(
color,
200f, 140f, false,
Offset(size.width * 0.32f, size.height * 0.18f),
Size(size.width * 0.36f, size.height * 0.4f),
style = Stroke(size.minDimension * 0.1f, cap = StrokeCap.Round),
)
}
}
@Composable
fun CheckIcon(color: Color, iconSize: Dp = 16.dp) {
DrawnIcon(iconSize) {
val p = Path().apply {
moveTo(size.width * 0.18f, size.height * 0.52f)
lineTo(size.width * 0.4f, size.height * 0.74f)
lineTo(size.width * 0.84f, size.height * 0.26f)
}
drawPath(p, color, style = Stroke(size.minDimension * 0.16f, cap = StrokeCap.Round))
}
}
@Composable
fun WifiIcon(color: Color, iconSize: Dp = 28.dp) {
DrawnIcon(iconSize) {
val c = Offset(size.width / 2, size.height * 0.72f)
drawCircle(color, size.minDimension * 0.06f, c)
for (i in 1..3) {
val r = size.minDimension * (0.16f + i * 0.16f)
drawArc(color, 210f, 120f, false, Offset(c.x - r, c.y - r), Size(r * 2, r * 2), style = Stroke(size.minDimension * 0.08f, cap = StrokeCap.Round))
}
}
}
@Composable
fun ChipIcon(color: Color, iconSize: Dp = 28.dp) {
DrawnIcon(iconSize) {
val m = size.minDimension * 0.22f
drawRoundRect(color, Offset(m, m), Size(size.width - 2 * m, size.height - 2 * m), androidx.compose.ui.geometry.CornerRadius(6f), style = Stroke(size.minDimension * 0.08f))
val pin = size.minDimension * 0.08f
for (i in 0 until 3) {
val t = m + (size.height - 2 * m) * (i + 1) / 4f
drawLine(color, Offset(m - pin, t), Offset(m, t), size.minDimension * 0.06f)
drawLine(color, Offset(size.width - m, t), Offset(size.width - m + pin, t), size.minDimension * 0.06f)
}
}
}
@Composable
fun GermanFlag(iconSize: Dp = 48.dp) {
DrawnIcon(iconSize) {
val w = this.size.width
val h = this.size.height
val clip = Path().apply { addOval(androidx.compose.ui.geometry.Rect(0f, 0f, w, h)) }
clipPath(clip) {
val band = h / 3f
drawRect(Color(0xFF2E2E30), size = Size(w, band))
drawRect(Color(0xFFB8453D), topLeft = Offset(0f, band), size = Size(w, band))
drawRect(Color(0xFFD6B352), topLeft = Offset(0f, 2 * band), size = Size(w, band))
}
}
}
@Composable
fun CzechFlag(iconSize: Dp = 48.dp) {
DrawnIcon(iconSize) {
val w = this.size.width
val h = this.size.height
val clip = Path().apply { addOval(androidx.compose.ui.geometry.Rect(0f, 0f, w, h)) }
clipPath(clip) {
drawRect(Color(0xFFFAFAFA), size = Size(w, h))
drawRect(Color(0xFFD4292E), topLeft = Offset(0f, h / 2), size = Size(w, h / 2))
val tri = Path().apply {
moveTo(0f, 0f)
lineTo(w * 0.55f, h / 2)
lineTo(0f, h)
close()
}
drawPath(tri, Color(0xFF123D8C))
}
}
}
@Composable
fun BookIcon(color: Color, iconSize: Dp = 22.dp) {
DrawnIcon(iconSize) {
drawRoundRect(color, Offset(size.width * 0.22f, size.height * 0.18f), Size(size.width * 0.56f, size.height * 0.64f), androidx.compose.ui.geometry.CornerRadius(4f), style = Stroke(size.minDimension * 0.08f))
drawLine(color, Offset(size.width * 0.38f, size.height * 0.18f), Offset(size.width * 0.38f, size.height * 0.82f), size.minDimension * 0.08f)
}
}
@Composable
fun QuizIcon(color: Color, iconSize: Dp = 22.dp) {
DrawnIcon(iconSize) {
drawCircle(color, size.minDimension * 0.36f, Offset(size.width / 2, size.height / 2), style = Stroke(size.minDimension * 0.08f))
drawCircle(color, size.minDimension * 0.06f, Offset(size.width / 2, size.height * 0.68f))
drawLine(color, Offset(size.width / 2, size.height * 0.32f), Offset(size.width / 2, size.height * 0.52f), size.minDimension * 0.08f, StrokeCap.Round)
}
}
@Composable
fun OrderIcon(color: Color, iconSize: Dp = 22.dp) {
DrawnIcon(iconSize) {
for (i in 0 until 3) {
val y = size.height * (0.28f + i * 0.22f)
drawLine(color, Offset(size.width * 0.22f, y), Offset(size.width * 0.78f, y), size.minDimension * 0.08f, StrokeCap.Round)
}
}
}
@Composable
fun GlobeIcon(color: Color, iconSize: Dp = 22.dp) {
DrawnIcon(iconSize) {
drawCircle(color, size.minDimension * 0.34f, center, style = Stroke(size.minDimension * 0.08f))
drawOval(color, Offset(size.width * 0.32f, size.height * 0.18f), Size(size.width * 0.36f, size.height * 0.64f), style = Stroke(size.minDimension * 0.06f))
}
}
@Composable
fun PeopleIcon(color: Color, iconSize: Dp = 22.dp) {
DrawnIcon(iconSize) {
drawCircle(color, size.minDimension * 0.12f, Offset(size.width * 0.35f, size.height * 0.32f))
drawCircle(color, size.minDimension * 0.12f, Offset(size.width * 0.65f, size.height * 0.32f))
drawArc(color, 200f, 140f, false, Offset(size.width * 0.18f, size.height * 0.42f), Size(size.width * 0.34f, size.height * 0.4f), style = Stroke(size.minDimension * 0.08f, cap = StrokeCap.Round))
drawArc(color, 200f, 140f, false, Offset(size.width * 0.48f, size.height * 0.42f), Size(size.width * 0.34f, size.height * 0.4f), style = Stroke(size.minDimension * 0.08f, cap = StrokeCap.Round))
}
}
@Composable
fun BulbIcon(color: Color, iconSize: Dp = 22.dp) {
DrawnIcon(iconSize) {
drawCircle(color, size.minDimension * 0.22f, Offset(size.width / 2, size.height * 0.4f), style = Stroke(size.minDimension * 0.08f))
drawRoundRect(color, Offset(size.width * 0.4f, size.height * 0.58f), Size(size.width * 0.2f, size.height * 0.2f), androidx.compose.ui.geometry.CornerRadius(3f))
}
}
@Composable
fun FinishFlag(p: Palette, number: String, iconSize: Dp) {
DrawnIcon(iconSize) {
val r = size.minDimension / 2f - 2f
drawCircle(p.finishDark, r)
drawCircle(p.finishStroke, r, style = Stroke(2f))
val cell = 6f
val fx = center.x - 1.5f * cell
val fy = center.y + 8f
for (iy in 0 until 2) for (ix in 0 until 3) {
val c = if ((ix + iy) and 1 == 1) p.finishLight else p.finishStroke
drawRect(c, Offset(fx + ix * cell, fy + iy * cell), Size(cell, cell))
}
}
}
fun noteIcon(name: String, color: Color): @Composable () -> Unit = {
when (name) {
"globe" -> GlobeIcon(color)
"people" -> PeopleIcon(color)
"bulb" -> BulbIcon(color)
"quiz" -> QuizIcon(color)
"order" -> OrderIcon(color)
else -> BookIcon(color)
}
}

View file

@ -0,0 +1,236 @@
package org.maturita.maturita.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.maturita.maturita.data.Palette
import kotlin.math.PI
import kotlin.math.min
import kotlin.math.sin
data class MapNode(
val id: String,
val label: String,
val locked: Boolean,
val done: Boolean,
val current: Boolean,
val finish: Boolean = false,
val onClick: (() -> Unit)? = null,
)
data class NodePos(val x: Float, val y: Float)
data class Serpentine(
val points: List<NodePos>,
val width: Float,
val height: Float,
val litUntil: Float,
)
fun serpentine(
n: Int,
avail: Float,
mx: Float,
my: Float,
spac: Float,
gap: Float,
wave: Float,
lift: Float = 0f,
minScale: Float = 0.55f,
): Serpentine {
if (n < 1) return Serpentine(emptyList(), avail, my * 2, 0f)
val scale = min(1f, maxOf(minScale, avail / (2 * mx + maxOf(n - 1, 0) * spac)))
val smx = mx * scale
val smy = my * scale
val sspac = spac * scale
val sgap = gap * scale
val swave = wave * scale
val slift = lift * scale
var rows = 1
while (rows <= n) {
val cols = (n + rows - 1) / rows
if (2 * smx + (cols - 1) * sspac <= avail + 1) break
rows++
}
rows = min(rows, n)
val cols = maxOf(1, (n + rows - 1) / rows)
val cw = 2 * smx + (cols - 1) * sspac
val ch = 2 * smy + (rows - 1) * sgap + slift
val phase = (2.0 * PI * 1.7 / maxOf(n - 1, 1)).toFloat()
val pts = MutableList(n) { NodePos(0f, 0f) }
for (r in 0 until rows) {
val base = r * cols
val len = min(cols, n - base)
val fwd = r % 2 == 0
for (c in 0 until len) {
val i = base + c
val cc = if (fwd) c else cols - 1 - c
pts[i] = NodePos(
smx + cc * sspac,
smy + slift + r * sgap + swave * sin(i * phase),
)
}
}
return Serpentine(pts, cw, ch, 0f)
}
private fun catmull(pts: List<NodePos>, t: Float): NodePos {
val n = pts.size
if (n == 1) return pts[0]
var k = t.toInt()
var u = t - k
if (k < 0) { k = 0; u = 0f }
if (k >= n - 1) { k = n - 2; u = 1f }
val p1 = pts[k]
val p2 = pts[k + 1]
val p0 = if (k - 1 >= 0) pts[k - 1] else NodePos(p1.x - (p2.x - p1.x), p1.y - (p2.y - p1.y))
val p3 = if (k + 2 < n) pts[k + 2] else NodePos(p2.x + (p2.x - p1.x), p2.y + (p2.y - p1.y))
val u2 = u * u
val u3 = u2 * u
val x = 0.5f * (2 * p1.x + (-p0.x + p2.x) * u + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * u2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * u3)
val y = 0.5f * (2 * p1.y + (-p0.y + p2.y) * u + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * u2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * u3)
return NodePos(x, y)
}
@Composable
fun PathMap(
nodes: List<MapNode>,
palette: Palette,
litUntil: Float,
nodeSize: Float = 72f,
mx: Float = 90f,
my: Float = 110f,
spac: Float = 170f,
gap: Float = 170f,
wave: Float = 28f,
lift: Float = 0f,
extra: List<Pair<NodePos, @Composable () -> Unit>> = emptyList(),
extraPoints: List<NodePos> = emptyList(),
) {
if (nodes.isEmpty()) return
val density = LocalDensity.current
fun Float.toDp(): androidx.compose.ui.unit.Dp = with(density) { this@toDp.toDp() }
BoxWithConstraints(Modifier.fillMaxSize()) {
val avail = constraints.maxWidth.toFloat()
val geo = serpentine(nodes.size, avail, mx, my, spac, gap, wave, lift)
Box(
Modifier
.horizontalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()),
) {
Box(Modifier.width(geo.width.toDp()).height((geo.height + 80f).toDp())) {
Canvas(Modifier.fillMaxSize()) {
if (geo.points.size < 2) return@Canvas
fun pathBetween(t0: Float, t1: Float): Path {
val p = Path()
val steps = maxOf(1, ((t1 - t0) * 48).toInt())
val start = catmull(geo.points, t0)
p.moveTo(start.x, start.y)
for (s in 1..steps) {
val t = t0 + (t1 - t0) * s / steps
val pt = catmull(geo.points, t)
p.lineTo(pt.x, pt.y)
}
return p
}
val end = (nodes.size - 1).toFloat()
drawPath(pathBetween(0f, end), palette.rail, style = Stroke(12f, cap = StrokeCap.Round, join = StrokeJoin.Round))
if (litUntil > 0f) {
drawPath(pathBetween(0f, litUntil), palette.accent, style = Stroke(12f, cap = StrokeCap.Round, join = StrokeJoin.Round))
}
extraPoints.forEach { }
}
nodes.forEachIndexed { i, node ->
val pt = geo.points[i]
Box(
Modifier
.offset { IntOffset((pt.x - nodeSize / 2).toInt(), (pt.y - nodeSize / 2).toInt()) }
.size(nodeSize.toDp()),
contentAlignment = Alignment.Center,
) {
val bg = when {
node.locked -> palette.lockedBg
node.current || node.done -> palette.accent
else -> palette.node
}
val fg = when {
node.locked -> palette.overlay
node.current || node.done -> palette.onAccent
else -> palette.text
}
Box(
Modifier
.fillMaxSize()
.clip(CircleShape)
.background(bg)
.then(
if (node.locked) Modifier.border(1.dp, palette.lockedBorder, CircleShape)
else Modifier
)
.then(if (!node.locked && node.onClick != null) Modifier.clickable { node.onClick.invoke() } else Modifier),
contentAlignment = Alignment.Center,
) {
if (node.finish) {
FinishFlag(palette, "${i + 1}", nodeSize.toDp())
} else {
androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("${i + 1}", color = fg, fontWeight = FontWeight.Black, fontSize = 18.sp)
if (node.locked) LockIcon(palette.overlay, 14.dp)
else if (node.done) CheckIcon(palette.onAccent, 14.dp)
}
}
}
}
Text(
node.label,
color = if (node.locked) palette.overlay else palette.text,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
modifier = Modifier
.offset { IntOffset((pt.x - 70).toInt(), (pt.y + nodeSize / 2 + 8).toInt()) }
.width(140.dp),
)
}
extra.forEach { (pt, content) ->
Box(Modifier.offset { IntOffset(pt.x.toInt(), pt.y.toInt()) }) { content() }
}
}
}
}
}
fun branchPoint(points: List<NodePos>, index: Int = 1, dx: Float = 40f, lift: Float = 110f): NodePos? {
if (index !in points.indices) return null
val p = points[index]
return NodePos(p.x + dx, p.y - lift)
}

View file

@ -0,0 +1,637 @@
package org.maturita.maturita.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.maturita.maturita.AppViewModel
import org.maturita.maturita.Route
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.data.J
import org.maturita.maturita.data.ProgressSum
import org.maturita.maturita.data.ThemeId
import org.maturita.maturita.data.UiLang
import org.maturita.maturita.data.normalizeAnswer
import org.maturita.maturita.data.summarize
import org.maturita.maturita.data.themeNames
import org.maturita.maturita.data.themePalette
@Composable
fun WelcomeScreen(vm: AppViewModel) {
val p = vm.palette
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Text("maturita.c", color = p.text, fontWeight = FontWeight.Bold, fontSize = 15.sp)
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(vm.tr("welcome_title"), color = p.text, fontSize = 36.sp, fontWeight = FontWeight.Bold, lineHeight = 40.sp)
Spacer(Modifier.height(12.dp))
Text(
vm.tr("welcome_body_android"),
color = p.subtext,
fontSize = 16.sp,
)
Spacer(Modifier.height(22.dp))
PrimaryButton(vm.tr("continue"), p) { vm.go(Route.Subjects) }
}
Spacer(Modifier.width(12.dp))
Box(
Modifier
.size(108.dp)
.clip(CircleShape)
.background(p.mantle)
.border(1.dp, p.text.copy(0.08f), CircleShape),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(vm.tr("welcome_stat_value"), color = p.text, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text(vm.tr("welcome_stat_label"), color = p.subtext, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center)
}
}
}
Spacer(Modifier.height(28.dp))
FeatureCard(vm, "welcome_feat1_title", "welcome_feat1_body", "welcome_feat1_link")
Spacer(Modifier.height(12.dp))
FeatureCard(vm, "welcome_feat2_title", "welcome_feat2_body", "welcome_feat2_link")
}
}
@Composable
private fun FeatureCard(vm: AppViewModel, t: String, b: String, l: String) {
val p = vm.palette
CardBox(p, Modifier.fillMaxWidth()) {
Column {
Text(vm.tr(t), color = p.text, fontSize = 18.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))
Text(vm.tr(b), color = p.subtext, fontSize = 15.sp)
Spacer(Modifier.height(10.dp))
PillButton(vm.tr(l), p) { vm.go(Route.Subjects) }
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SubjectsScreen(vm: AppViewModel) {
val p = vm.palette
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, vm.tr("subjects_title"), vm.tr("subjects_sub"))
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) {
val cell = (maxWidth - 12.dp) / 2
FlowRow(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
maxItemsInEachRow = 2,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
vm.content.subjects.forEach { s ->
val open = s.bool("open")
Box(
Modifier
.width(cell)
.height(132.dp)
.clip(RoundedCornerShape(20.dp))
.background(p.mantle)
.border(1.dp, if (open) p.text.copy(0.08f) else p.text.copy(0.04f), RoundedCornerShape(20.dp))
.alpha(if (open) 1f else 0.48f)
.then(if (open) Modifier.clickable { vm.goPage(s.str("target")) } else Modifier)
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
when (s.str("icon")) {
"de" -> GermanFlag(44.dp)
"wifi" -> WifiIcon(p.text, 36.dp)
"chip" -> ChipIcon(p.text, 36.dp)
"cz" -> CzechFlag(44.dp)
else -> LockIcon(p.overlay, 22.dp)
}
Spacer(Modifier.height(10.dp))
Text(vm.tr(s.str("key")), color = p.text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center)
}
}
}
}
}
}
}
@Composable
fun SettingsSheet(vm: AppViewModel) {
val p = vm.palette
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text(vm.tr("settings_title"), color = p.text, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
Text(vm.tr("mode"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
Segmented(p) {
SegChip(vm.tr("mode_dark"), vm.mode == ColorMode.Dark, p, Modifier.weight(1f)) { vm.applyMode(ColorMode.Dark) }
SegChip(vm.tr("mode_light"), vm.mode == ColorMode.Light, p, Modifier.weight(1f)) { vm.applyMode(ColorMode.Light) }
}
Spacer(Modifier.height(16.dp))
Text(vm.tr("theme"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
themeNames.forEachIndexed { i, name ->
val id = ThemeId.entries[i]
val sw = themePalette(id, vm.mode)
val sel = vm.themeId == id
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(if (sel) p.surface1 else p.surface0)
.clickable { vm.setTheme(id) }
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Canvas(Modifier.size(36.dp)) {
drawCircle(sw.base)
drawCircle(sw.accent, 5f, Offset(size.width * 0.28f, size.height * 0.5f))
drawCircle(sw.accent3, 5f, Offset(size.width * 0.5f, size.height * 0.5f))
drawCircle(sw.success, 5f, Offset(size.width * 0.72f, size.height * 0.5f))
if (sel) drawCircle(p.accent, size.minDimension / 2 - 1f, style = androidx.compose.ui.graphics.drawscope.Stroke(2.2f))
}
Spacer(Modifier.width(10.dp))
Text(name, color = p.text, fontWeight = FontWeight.Medium)
}
Spacer(Modifier.height(6.dp))
}
Spacer(Modifier.height(10.dp))
Text(vm.tr("language"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
Segmented(p) {
SegChip("Čeština", vm.lang == UiLang.Cs, p, Modifier.weight(1f)) { vm.applyLang(UiLang.Cs) }
SegChip("English", vm.lang == UiLang.En, p, Modifier.weight(1f)) { vm.applyLang(UiLang.En) }
}
Spacer(Modifier.height(16.dp))
Text(vm.tr("updates"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
Text(
vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())),
color = p.subtext,
fontSize = 14.sp,
)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PillButton(vm.tr("update_check"), p) { vm.checkUpdate(true) }
if (vm.update.canInstall) PrimaryButton(vm.tr("update_install"), p) { vm.installUpdate() }
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
fun StatsScreen(vm: AppViewModel) {
val p = vm.palette
val sum = summarize(vm.content, vm.progress)
val pct = if (sum.totalEx == 0) 0 else (100 * sum.doneEx / sum.totalEx)
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, vm.tr("stats_title"), vm.tr("stats_sub"))
Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Metric(p, vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx), Modifier.weight(1f))
Metric(p, vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", pct), Modifier.weight(1f))
Metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits), Modifier.weight(1f))
}
Spacer(Modifier.height(10.dp))
LinearProgressIndicator(pct / 100f, Modifier.fillMaxWidth().height(8.dp).clip(CircleShape), p.accent, p.surface1, StrokeCap.Round)
Spacer(Modifier.height(20.dp))
Text(vm.tr("stats_section"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
vm.content.subjects.forEach { s ->
val open = s.bool("open")
val part = subjectSum(vm, s)
CardBox(p, Modifier.fillMaxWidth().padding(bottom = 10.dp).alpha(if (open) 1f else 0.55f)) {
Column {
Text(vm.tr(s.str("key")), color = p.text, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
Text(
if (open) vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) else vm.tr("stats_locked"),
color = p.subtext,
fontSize = 13.sp,
)
if (open && part.totalEx > 0) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
part.doneEx / part.totalEx.toFloat(),
Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
p.accent, p.surface1, StrokeCap.Round,
)
}
}
}
}
}
}
}
@Composable
private fun Metric(p: org.maturita.maturita.data.Palette, label: String, value: String, modifier: Modifier) {
CardBox(p, modifier) {
Column {
Text(label, color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text(value, color = p.text, fontSize = 22.sp, fontWeight = FontWeight.Bold)
}
}
}
private fun subjectSum(vm: AppViewModel, s: J): ProgressSum {
val key = s.str("key")
val sum = ProgressSum()
when (s.str("target")) {
"roadmap" -> vm.content.german.filter { it.bool("unlocked") }.forEach { u ->
val n = u.strs("names").size
sum.totalEx += n
sum.doneEx += (1..n).count { vm.progress.germanDone(u.int("id"), it) }
}
"netyears" -> {
sum.totalEx = vm.content.netLessons.size
sum.doneEx = vm.content.netLessons.count { vm.progress.netDone(it.int("id")) }
}
"hwmap" -> {
sum.totalEx = vm.content.hw.size
sum.doneEx = vm.content.hw.count { vm.progress.hwDone(it.int("id")) }
}
"czechmap" -> {
sum.totalEx = vm.content.mluvnice.size
sum.doneEx = vm.content.mluvnice.count { vm.progress.mluvDone(it.int("id")) }
}
}
return sum
}
@Composable
fun SearchOverlay(vm: AppViewModel) {
val p = vm.palette
val q = normalizeAnswer(vm.searchQuery)
val hits = buildSearch(vm).filter { q.isEmpty() || normalizeAnswer(it.hay).contains(q) }.take(24)
Column(
Modifier
.fillMaxSize()
.background(p.base.copy(alpha = 0.96f))
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.background(p.mantle)
.padding(14.dp),
) {
if (vm.searchQuery.isEmpty()) Text(vm.tr("search_placeholder"), color = p.overlay)
BasicTextField(
vm.searchQuery,
{ vm.searchQuery = it },
textStyle = TextStyle(color = p.text, fontSize = 16.sp),
cursorBrush = SolidColor(p.accent),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
Spacer(Modifier.width(8.dp))
PillButton(vm.tr("back"), p) { vm.searchOpen = false }
}
Spacer(Modifier.height(12.dp))
if (hits.isEmpty()) {
Text(if (q.isEmpty()) vm.tr("search_hint") else vm.tr("search_empty"), color = p.subtext)
} else {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
hits.forEach { hit ->
CardBox(p, Modifier.fillMaxWidth().clickable {
if (!hit.locked) {
vm.goPage(hit.target)
vm.searchOpen = false
}
}.alpha(if (hit.locked) 0.5f else 1f)) {
Column {
Text(hit.title, color = p.text, fontWeight = FontWeight.SemiBold)
Text(hit.sub, color = p.subtext, fontSize = 13.sp)
}
}
}
}
}
}
}
private data class Hit(val title: String, val sub: String, val target: String, val locked: Boolean, val hay: String)
private fun buildSearch(vm: AppViewModel): List<Hit> {
val out = mutableListOf<Hit>()
fun add(title: String, sub: String, target: String, locked: Boolean = false, extra: String = "") {
out += Hit(title, sub, target, locked, "$title $sub $target $extra")
}
add(vm.tr("subjects_title"), vm.tr("search_page"), "subjects", extra = "predmety")
add(vm.tr("stats"), vm.tr("search_page"), "stats")
add(vm.tr("search_home"), vm.tr("search_page"), "welcome")
vm.content.subjects.forEach { s ->
add(vm.tr(s.str("key")), vm.tr("search_subject"), s.strOrNull("target") ?: "subjects", !s.bool("open"))
}
vm.content.german.forEach { u ->
val unlocked = u.bool("unlocked")
add(u.str("title"), vm.tr("search_unit"), u.strOrNull("page") ?: "roadmap", !unlocked, "deutsch")
if (unlocked) {
u.strs("names").forEachIndexed { i, name ->
add(name, u.str("title"), "u${u.int("id") + 1}e${i + 1}")
}
if (u.has("branch")) add("Vokabeltraining", u.str("title"), u.str("branch"), extra = "vokabel")
}
}
vm.content.netLessons.forEach { l ->
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "netunit${l.int("id")}", extra = "site")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "netex${l.int("id")}")
}
vm.content.hw.forEach { l ->
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "hwunit${l.int("id")}", extra = "hardware")
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "hwex${l.int("id")}")
}
add(vm.tr("Mluvnice"), vm.tr("search_lesson"), "mluvnice")
add(vm.tr("Maturitní četba"), vm.tr("search_book"), "readinglist")
vm.content.books.forEach { b ->
add(b.str("title"), vm.tr("search_book"), b.str("page"))
}
return out
}
@Composable
fun RoadmapScreen(vm: AppViewModel) {
val p = vm.palette
val current = vm.content.german.indexOfFirst { u ->
u.bool("unlocked") && u.strs("names").indices.any { !vm.progress.germanDone(u.int("id"), it + 1) }
}
Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
PageTop(vm, { vm.back() }, vm.tr("roadmap_title"), vm.tr("roadmap_sub"))
PathMap(
nodes = vm.content.german.mapIndexed { i, u ->
val unlocked = u.bool("unlocked")
val names = u.strs("names")
val done = unlocked && names.isNotEmpty() && names.indices.all { vm.progress.germanDone(u.int("id"), it + 1) }
MapNode(
id = u.str("page"),
label = u.str("title"),
locked = !unlocked,
done = done,
current = i == current,
finish = i == vm.content.german.lastIndex,
onClick = if (unlocked) ({ vm.go(Route.UnitMap(u.int("id"))) }) else null,
)
},
palette = p,
litUntil = 2f,
)
}
}
@Composable
fun UnitMapScreen(vm: AppViewModel, unitId: Int) {
val u = vm.content.germanUnit(unitId) ?: return
val p = vm.palette
val names = u.strs("names")
Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
PageTop(vm, { vm.back() }, u.str("title"), vm.tr(u.str("sub")))
val next = names.indices.firstOrNull { !vm.progress.germanDone(unitId, it + 1) }
PathMap(
nodes = names.mapIndexed { i, name ->
val n = i + 1
MapNode(
id = "e$n",
label = name,
locked = false,
done = vm.progress.germanDone(unitId, n),
current = i == next,
onClick = { vm.go(Route.GermanEx(unitId, n)) },
)
},
palette = p,
litUntil = names.indices.count { vm.progress.germanDone(unitId, it + 1) }.toFloat().coerceAtMost((names.size - 1).toFloat()),
nodeSize = 58f,
spac = 140f,
mx = 70f,
lift = if (u.has("branch")) 110f else 0f,
)
if (u.has("branch")) {
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
PrimaryButton(u.str("vocab").let { vm.tr("Vokabeltraining") }, p) { vm.go(Route.Vocab(unitId)) }
}
}
}
}
@Composable
fun NetYearsScreen(vm: AppViewModel) {
val p = vm.palette
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, vm.tr("net_years_title"), vm.tr("net_years_sub"))
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
YearCard(vm, vm.tr("net_year1"), vm.tr("net_sub"), true) { vm.go(Route.NetMap) }
listOf("net_year2", "net_year3", "net_year4").forEach { key ->
YearCard(vm, vm.tr(key), vm.tr("net_year_locked_sub"), false) {}
}
Spacer(Modifier.height(16.dp))
}
}
}
@Composable
private fun YearCard(vm: AppViewModel, title: String, sub: String, open: Boolean, onClick: () -> Unit) {
val p = vm.palette
CardBox(p, Modifier.fillMaxWidth().alpha(if (open) 1f else 0.48f).then(if (open) Modifier.clickable(onClick = onClick) else Modifier)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(title, color = p.text, fontWeight = FontWeight.Bold, fontSize = 18.sp)
Text(sub, color = p.subtext, fontSize = 14.sp)
}
if (!open) LockIcon(p.overlay)
}
}
}
@Composable
fun LessonMapScreen(
vm: AppViewModel,
title: String,
sub: String,
nodes: List<MapNode>,
litUntil: Float,
) {
Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
PageTop(vm, { vm.back() }, title, sub)
PathMap(nodes, vm.palette, litUntil, nodeSize = 64f, spac = 150f, mx = 70f)
}
}
@Composable
fun SlidesScreen(
vm: AppViewModel,
title: String,
sub: String,
slides: List<J>,
onExercise: () -> Unit,
) {
val p = vm.palette
var idx = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(0) }
val slide = slides.getOrNull(idx.intValue) ?: return
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, title, sub)
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
Text(slide.str("kicker"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text(slide.str("title"), color = p.text, fontSize = 24.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 6.dp, bottom = 12.dp))
slide.strOrNull("tip")?.let { CardBox(p, Modifier.fillMaxWidth().padding(bottom = 12.dp)) { Text(it, color = p.text) } }
slide.strs("lines").forEach { line ->
CardBox(p, Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Text(line, color = p.text, fontSize = 16.sp)
}
}
}
Row(Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween) {
if (idx.intValue > 0) PillButton(vm.tr("net_slide_prev"), p) { idx.intValue-- }
else Spacer(Modifier.width(1.dp))
if (idx.intValue < slides.lastIndex) PrimaryButton(vm.tr("net_slide_next"), p) { idx.intValue++ }
else PrimaryButton(vm.tr("net_slide_start"), p, onClick = onExercise)
}
}
}
@Composable
fun CzechMapScreen(vm: AppViewModel) {
val p = vm.palette
val items = listOf(
Triple("L", vm.tr("Literatura"), null),
Triple("M", vm.tr("Mluvnice"), Route.Mluvnice as Route?),
Triple("Č", vm.tr("Maturitní četba"), Route.ReadingList as Route?),
)
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, vm.tr("Český jazyk a literatura"), vm.tr("czech_sub"))
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
items.forEach { (letter, name, route) ->
CardBox(p, Modifier.fillMaxWidth().alpha(if (route == null) 0.48f else 1f).then(
if (route != null) Modifier.clickable { vm.go(route) } else Modifier
)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(56.dp).clip(CircleShape).background(if (route == null) p.lockedBg else p.accent),
contentAlignment = Alignment.Center,
) {
if (route == null) LockIcon(p.overlay) else Text(letter, color = p.onAccent, fontWeight = FontWeight.Black, fontSize = 20.sp)
}
Spacer(Modifier.width(14.dp))
Text(name, color = p.text, fontWeight = FontWeight.SemiBold, fontSize = 16.sp)
}
}
}
}
}
}
@Composable
fun BookListScreen(vm: AppViewModel) {
val p = vm.palette
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, vm.tr("Maturitní četba"), vm.tr("reading_sub"))
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
vm.content.books.forEach { b ->
CardBox(p, Modifier.fillMaxWidth().clickable { vm.go(Route.Book(b.str("id"))) }) {
Row(verticalAlignment = Alignment.CenterVertically) {
BookIcon(p.text, 28.dp)
Spacer(Modifier.width(12.dp))
Column {
Text(b.str("title"), color = p.text, fontWeight = FontWeight.Bold)
Text(vm.tr(b.str("subKey")), color = p.subtext, fontSize = 13.sp)
}
}
}
}
}
}
}
@Composable
fun BookScreen(vm: AppViewModel, id: String) {
val b = vm.content.book(id) ?: return
val p = vm.palette
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, b.str("title"), vm.tr(b.str("subKey")))
Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
CardBox(p, Modifier.fillMaxWidth().clickable { vm.go(Route.BookQuiz(id)) }) {
Row(verticalAlignment = Alignment.CenterVertically) {
QuizIcon(p.text); Spacer(Modifier.width(10.dp))
Column {
Text(vm.tr(b.str("quizTitle")), color = p.text, fontWeight = FontWeight.Bold)
Text(vm.tr(b.str("quizSub")), color = p.subtext, fontSize = 13.sp)
}
}
}
CardBox(p, Modifier.fillMaxWidth().clickable { vm.go(Route.BookPlot(id)) }) {
Row(verticalAlignment = Alignment.CenterVertically) {
OrderIcon(p.text); Spacer(Modifier.width(10.dp))
Column {
Text(vm.tr(b.str("plotTitle")), color = p.text, fontWeight = FontWeight.Bold)
Text(vm.tr(b.str("plotSub")), color = p.subtext, fontSize = 13.sp)
}
}
}
b.arr("notes").forEach { note ->
CardBox(p, Modifier.fillMaxWidth()) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
noteIcon(note.str("icon"), p.text)()
Spacer(Modifier.width(8.dp))
Text(note.str("title"), color = p.text, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(8.dp))
note.strs("lines").forEach {
Text("• $it", color = p.text, fontSize = 15.sp, modifier = Modifier.padding(bottom = 4.dp))
}
}
}
}
}
}
}

View file

@ -0,0 +1,109 @@
package org.maturita.maturita.update
import android.app.Application
import android.content.Intent
import android.os.Handler
import android.os.Looper
import androidx.core.content.FileProvider
import org.maturita.maturita.BuildConfig
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
data class UpdateState(
val status: String = "idle",
val remote: String? = null,
val messageKey: String = "update_current",
val messageArg: String? = BuildConfig.COMMIT.take(7),
val canInstall: Boolean = false,
)
class Updater(private val app: Application) {
private val io = Executors.newSingleThreadExecutor()
private val main = Handler(Looper.getMainLooper())
private fun emit(cb: (UpdateState) -> Unit, state: UpdateState) {
main.post { cb(state) }
}
fun check(interactive: Boolean, cb: (UpdateState) -> Unit) {
if (BuildConfig.COMMIT == "dev") {
emit(cb, UpdateState(status = "dev", messageKey = "update_err_devbuild", messageArg = null))
return
}
emit(cb, UpdateState(status = "checking", messageKey = "update_checking", messageArg = null))
io.execute {
try {
val atom = get("https://github.com/${BuildConfig.UPDATE_REPO}/commits/${BuildConfig.UPDATE_BRANCH}.atom")
val sha = Regex("""<id>.*?/commit/([0-9a-f]{7,40})</id>""", RegexOption.IGNORE_CASE)
.find(atom)?.groupValues?.get(1)
?: throw IllegalStateException("sha")
val version = get(
"https://raw.githubusercontent.com/${BuildConfig.UPDATE_REPO}/$sha/VERSION",
).trim()
val local = BuildConfig.COMMIT
if (version.startsWith(local) || local.startsWith(version)) {
emit(cb, UpdateState("ok", version, "update_uptodate", version.take(7)))
} else {
emit(cb, UpdateState("available", version, "update_available", version.take(7), true))
}
} catch (_: Exception) {
emit(cb, UpdateState("error", messageKey = "update_err_network", messageArg = null))
}
}
}
fun install(cb: (UpdateState) -> Unit) {
emit(cb, UpdateState(status = "downloading", messageKey = "update_downloading", messageArg = null))
io.execute {
try {
val atom = get("https://github.com/${BuildConfig.UPDATE_REPO}/commits/${BuildConfig.UPDATE_BRANCH}.atom")
val sha = Regex("""<id>.*?/commit/([0-9a-f]{7,40})</id>""", RegexOption.IGNORE_CASE)
.find(atom)?.groupValues?.get(1)
?: throw IllegalStateException("sha")
val url = "https://raw.githubusercontent.com/${BuildConfig.UPDATE_REPO}/$sha/maturita-android.apk"
val dir = File(app.cacheDir, "updates").apply { mkdirs() }
val apk = File(dir, "maturita.apk")
download(url, apk)
val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
main.post { app.startActivity(intent) }
emit(cb, UpdateState("staged", sha, "update_staged", sha.take(7)))
} catch (_: Exception) {
emit(cb, UpdateState("error", messageKey = "update_err_download", messageArg = null))
}
}
}
private fun get(url: String): String {
val c = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = 15000
instanceFollowRedirects = true
}
return try {
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
c.inputStream.bufferedReader().use { it.readText() }
} finally {
c.disconnect()
}
}
private fun download(url: String, dest: File) {
val c = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 20000
readTimeout = 60000
instanceFollowRedirects = true
}
try {
if (c.responseCode !in 200..299) error("http ${c.responseCode}")
c.inputStream.use { input -> dest.outputStream().use { input.copyTo(it) } }
} finally {
c.disconnect()
}
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_fg" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_fg" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_bg">#FAF7F2</color>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Maturita" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">#FAF7F2</item>
</style>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updates" path="updates/" />
</paths>