Add a Jetpack Compose Android app that plays the same maturita content.
Co-authored-by: Cursor <cursoragent@cursor.com>
73
android/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
val commit: String = System.getenv("COMMIT")?.takeIf { it.isNotBlank() } ?: "dev"
|
||||
|
||||
android {
|
||||
namespace = "org.maturita.maturita"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.maturita.maturita"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "android"
|
||||
buildConfigField("String", "COMMIT", "\"$commit\"")
|
||||
buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"")
|
||||
buildConfigField("String", "UPDATE_BRANCH", "\"builds\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
|
||||
val extractContent = tasks.register<Exec>("extractContent") {
|
||||
workingDir = rootProject.projectDir.parentFile
|
||||
commandLine("python3", "scripts/extract-android-content.py")
|
||||
inputs.dir(rootProject.projectDir.parentFile.resolve("src"))
|
||||
outputs.file(file("src/main/assets/content.json"))
|
||||
}
|
||||
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn(extractContent)
|
||||
}
|
||||
1
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Debug-signed student builds keep the full content JSON.
|
||||
36
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
120
android/app/src/main/java/org/maturita/maturita/AppViewModel.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
195
android/app/src/main/java/org/maturita/maturita/MainActivity.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
71
android/app/src/main/java/org/maturita/maturita/Route.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 == 'Ü' }
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
52
android/app/src/main/java/org/maturita/maturita/data/Json.kt
Normal 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) } }
|
||||
|
|
@ -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
|
||||
}
|
||||
164
android/app/src/main/java/org/maturita/maturita/data/Theme.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
185
android/app/src/main/java/org/maturita/maturita/ui/Components.kt
Normal 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)
|
||||
}
|
||||
1029
android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt
Normal file
241
android/app/src/main/java/org/maturita/maturita/ui/Icons.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
236
android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt
Normal 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)
|
||||
}
|
||||
637
android/app/src/main/java/org/maturita/maturita/ui/Screens.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_fg.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_bg">#FAF7F2</color>
|
||||
</resources>
|
||||
8
android/app/src/main/res/values/themes.xml
Normal 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>
|
||||
4
android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||