diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 77a4644..36dc28a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "org.maturita.maturita" minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "android" + versionCode = 3 + versionName = "android-3" buildConfigField("String", "COMMIT", "\"$commit\"") buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"") buildConfigField("String", "UPDATE_BRANCH", "\"builds\"") diff --git a/android/app/src/main/java/org/maturita/maturita/MainActivity.kt b/android/app/src/main/java/org/maturita/maturita/MainActivity.kt index 4198d25..e8c4b7f 100644 --- a/android/app/src/main/java/org/maturita/maturita/MainActivity.kt +++ b/android/app/src/main/java/org/maturita/maturita/MainActivity.kt @@ -1,6 +1,12 @@ package org.maturita.maturita import android.os.Bundle +import android.util.TypedValue +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent @@ -60,9 +66,61 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val crashPrefs = getSharedPreferences("crash", MODE_PRIVATE) + val prev = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, error -> + try { + crashPrefs.edit().putString("last", error.stackTraceToString()).commit() + } catch (_: Exception) { + } + prev?.uncaughtException(thread, error) + } + val lastCrash = crashPrefs.getString("last", null) + if (lastCrash != null) { + setContentView(crashView(lastCrash) { + crashPrefs.edit().remove("last").apply() + recreate() + }) + return + } enableEdgeToEdge() setContent { MaturitaApp(vm) } } + + private fun crashView(dump: String, retry: () -> Unit): LinearLayout { + val pad = (20 * resources.displayMetrics.density).toInt() + val title = TextView(this).apply { + text = "maturita.c spadla" + setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) + setPadding(0, 0, 0, pad / 2) + } + val hint = TextView(this).apply { + text = "Pošli tenhle text, ať jde pád opravit." + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + setPadding(0, 0, 0, pad / 2) + } + val body = TextView(this).apply { + text = dump + setTextIsSelectable(true) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + typeface = android.graphics.Typeface.MONOSPACE + } + val scroll = ScrollView(this).apply { + addView(body, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + val retryBtn = Button(this).apply { + text = "Zkusit znovu" + setOnClickListener { retry() } + } + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(pad, pad, pad, pad) + addView(title, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + addView(hint, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + addView(retryBtn, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + } } @Composable diff --git a/android/app/src/main/java/org/maturita/maturita/data/Content.kt b/android/app/src/main/java/org/maturita/maturita/data/Content.kt index 10a3d75..3905a98 100644 --- a/android/app/src/main/java/org/maturita/maturita/data/Content.kt +++ b/android/app/src/main/java/org/maturita/maturita/data/Content.kt @@ -16,7 +16,7 @@ class Content(root: JSONObject) { } val subjects = raw.arr("subjects") val german = raw.arr("german") - val net = raw.obj("net")!! + val net = raw.obj("net") ?: J(JSONObject()) val netLessons = net.arr("lessons") val netTasks = net.arr("tasks") val netAnswers: List> = run { @@ -59,8 +59,12 @@ class Content(root: JSONObject) { companion object { fun load(context: Context): Content { - val text = context.assets.open("content.json").bufferedReader().use { it.readText() } - return Content(JSONObject(text)) + return try { + val text = context.assets.open("content.json").bufferedReader().use { it.readText() } + Content(JSONObject(text)) + } catch (_: Exception) { + Content(JSONObject()) + } } } } diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt b/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt index dfed1f2..5e0a265 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt @@ -19,8 +19,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.foundation.layout.heightIn import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -191,7 +190,8 @@ private fun AssemblyEx(vm: AppViewModel, title: String, sub: String?, spec: J, o FlowRow(Modifier.fillMaxWidth().padding(bottom = 6.dp).clip(RoundedCornerShape(14.dp)).background(p.surface0).padding(8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { if (placed[i].isEmpty()) Text("…", color = p.overlay) placed[i].forEach { idx -> - Chip(item.strs("words")[idx], p) { + val word = item.strs("words").getOrNull(idx) ?: return@forEach + Chip(word, p) { placed[i].remove(idx) pools[i].add(idx) } @@ -199,7 +199,8 @@ private fun AssemblyEx(vm: AppViewModel, title: String, sub: String?, spec: J, o } FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { pools[i].toList().forEach { idx -> - Chip(item.strs("words")[idx], p) { + val word = item.strs("words").getOrNull(idx) ?: return@forEach + Chip(word, p) { pools[i].remove(idx) placed[i].add(idx) } @@ -663,26 +664,43 @@ private fun ComboLine( val p = vm.palette var open by remember { mutableStateOf(false) } val box = @Composable { - Box { + Column { Box( Modifier .clip(RoundedCornerShape(12.dp)) .background(p.mantle) .border(1.dp, if (revealed && picks.getOrNull(idx).orEmpty().isNotEmpty()) { - if (answerAccepts(normalizeAnswer(picks[idx]), answer)) p.success else p.error + if (answerAccepts(normalizeAnswer(picks.getOrNull(idx).orEmpty()), answer)) p.success else p.error } else p.text.copy(0.1f), RoundedCornerShape(12.dp)) - .clickable { open = true } + .clickable { open = !open } .padding(horizontal = 12.dp, vertical = 8.dp), ) { val pick = picks.getOrNull(idx).orEmpty() Text(pick.ifEmpty { "—" }, color = if (pick.isEmpty()) p.overlay else p.text) } - DropdownMenu(open, { open = false }) { - pool.forEach { w -> - DropdownMenuItem({ Text(w) }, { - if (idx in picks.indices) picks[idx] = w - open = false - }) + if (open) { + Column( + Modifier + .padding(top = 4.dp) + .heightIn(max = 220.dp) + .verticalScroll(rememberScrollState()) + .clip(RoundedCornerShape(12.dp)) + .background(p.surface0) + .border(1.dp, p.text.copy(0.08f), RoundedCornerShape(12.dp)), + ) { + pool.forEach { w -> + Text( + w, + color = p.text, + modifier = Modifier + .fillMaxWidth() + .clickable { + if (idx in picks.indices) picks[idx] = w + open = false + } + .padding(horizontal = 12.dp, vertical = 8.dp), + ) + } } } } @@ -950,13 +968,28 @@ private fun VlsmEx(vm: AppViewModel, @Suppress("UNUSED_PARAMETER") lesson: J) { @Composable private fun PrefixMenu(value: String, p: Palette, onPick: (String) -> Unit) { var open by remember { mutableStateOf(false) } - Box(Modifier.padding(vertical = 4.dp)) { + Column(Modifier.padding(vertical = 4.dp)) { Box( - Modifier.clip(RoundedCornerShape(12.dp)).background(p.mantle).clickable { open = true }.padding(10.dp), + Modifier.clip(RoundedCornerShape(12.dp)).background(p.mantle).clickable { open = !open }.padding(10.dp), ) { Text(value.ifEmpty { "/…" }, color = p.text) } - DropdownMenu(open, { open = false }) { - (8..30).forEach { n -> - DropdownMenuItem({ Text("/$n") }, { onPick("/$n"); open = false }) + if (open) { + Column( + Modifier + .heightIn(max = 220.dp) + .verticalScroll(rememberScrollState()) + .clip(RoundedCornerShape(12.dp)) + .background(p.surface0), + ) { + (8..30).forEach { n -> + Text( + "/$n", + color = p.text, + modifier = Modifier + .fillMaxWidth() + .clickable { onPick("/$n"); open = false } + .padding(horizontal = 12.dp, vertical = 8.dp), + ) + } } } } diff --git a/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt b/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt index a0c57c6..595755a 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt @@ -1,41 +1,30 @@ 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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.fillMaxWidth 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.alpha 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, @@ -47,191 +36,73 @@ data class MapNode( val onClick: (() -> Unit)? = null, ) -data class NodePos(val x: Float, val y: Float) - -data class Serpentine( - val points: List, - val width: Float, - val height: Float, - val litUntil: Float, -) - -fun serpentine( - n: Int, - avail: Float, - mx: Float, - my: Float, - spac: Float, - gap: Float, - wave: Float, - lift: Float = 0f, - minScale: Float = 0.55f, -): Serpentine { - if (n < 1) return Serpentine(emptyList(), avail, my * 2, 0f) - val scale = min(1f, maxOf(minScale, avail / (2 * mx + maxOf(n - 1, 0) * spac))) - val smx = mx * scale - val smy = my * scale - val sspac = spac * scale - val sgap = gap * scale - val swave = wave * scale - val slift = lift * scale - var rows = 1 - while (rows <= n) { - val cols = (n + rows - 1) / rows - if (2 * smx + (cols - 1) * sspac <= avail + 1) break - rows++ - } - rows = min(rows, n) - val cols = maxOf(1, (n + rows - 1) / rows) - val cw = 2 * smx + (cols - 1) * sspac - val ch = 2 * smy + (rows - 1) * sgap + slift - val phase = (2.0 * PI * 1.7 / maxOf(n - 1, 1)).toFloat() - val pts = MutableList(n) { NodePos(0f, 0f) } - for (r in 0 until rows) { - val base = r * cols - val len = min(cols, n - base) - val fwd = r % 2 == 0 - for (c in 0 until len) { - val i = base + c - val cc = if (fwd) c else cols - 1 - c - pts[i] = NodePos( - smx + cc * sspac, - smy + slift + r * sgap + swave * sin(i * phase), - ) - } - } - return Serpentine(pts, cw, ch, 0f) -} - -private fun catmull(pts: List, t: Float): NodePos { - val n = pts.size - if (n == 1) return pts[0] - var k = t.toInt() - var u = t - k - if (k < 0) { k = 0; u = 0f } - if (k >= n - 1) { k = n - 2; u = 1f } - val p1 = pts[k] - val p2 = pts[k + 1] - val p0 = if (k - 1 >= 0) pts[k - 1] else NodePos(p1.x - (p2.x - p1.x), p1.y - (p2.y - p1.y)) - val p3 = if (k + 2 < n) pts[k + 2] else NodePos(p2.x + (p2.x - p1.x), p2.y + (p2.y - p1.y)) - val u2 = u * u - val u3 = u2 * u - val x = 0.5f * (2 * p1.x + (-p0.x + p2.x) * u + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * u2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * u3) - val y = 0.5f * (2 * p1.y + (-p0.y + p2.y) * u + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * u2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * u3) - return NodePos(x, y) -} - @Composable fun PathMap( nodes: List, palette: Palette, - litUntil: Float, + @Suppress("UNUSED_PARAMETER") litUntil: Float, modifier: Modifier = Modifier, - nodeSize: Float = 72f, - mx: Float = 90f, - my: Float = 110f, - spac: Float = 170f, - gap: Float = 170f, - wave: Float = 28f, - lift: Float = 0f, - extra: List Unit>> = emptyList(), - extraPoints: List = emptyList(), + @Suppress("UNUSED_PARAMETER") nodeSize: Float = 72f, + @Suppress("UNUSED_PARAMETER") mx: Float = 90f, + @Suppress("UNUSED_PARAMETER") my: Float = 110f, + @Suppress("UNUSED_PARAMETER") spac: Float = 170f, + @Suppress("UNUSED_PARAMETER") gap: Float = 170f, + @Suppress("UNUSED_PARAMETER") wave: Float = 28f, + @Suppress("UNUSED_PARAMETER") lift: Float = 0f, ) { - 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().coerceAtLeast(1f) - 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 + Column( + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + nodes.forEachIndexed { i, node -> + val click = node.onClick + CardBox( + palette, + Modifier + .fillMaxWidth() + .alpha(if (node.locked) 0.48f else 1f) + .then( + if (!node.locked && click != null) Modifier.clickable { click() } + else Modifier, + ), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val bg = when { + node.locked -> palette.lockedBg + node.current || node.done -> palette.accent + else -> palette.node } - 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)) + val fg = when { + node.locked -> palette.overlay + node.current || node.done -> palette.onAccent + else -> palette.text } - 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()), + .size(44.dp) + .clip(CircleShape) + .background(bg), 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) - } - } + when { + node.locked -> LockIcon(palette.overlay, 16.dp) + node.done -> CheckIcon(palette.onAccent, 16.dp) + else -> Text("${i + 1}", color = fg, fontWeight = FontWeight.Black, fontSize = 16.sp) } } + Spacer(Modifier.width(12.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), + fontWeight = if (node.current) FontWeight.Bold else FontWeight.SemiBold, + fontSize = 16.sp, + modifier = Modifier.weight(1f), ) } - extra.forEach { (pt, content) -> - Box(Modifier.offset { IntOffset(pt.x.toInt(), pt.y.toInt()) }) { content() } - } } } } } - -fun branchPoint(points: List, index: Int = 1, dx: Float = 40f, lift: Float = 110f): NodePos? { - if (index !in points.indices) return null - val p = points[index] - return NodePos(p.x + dx, p.y - lift) -} diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt b/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt index 710b0fa..2b2076d 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt @@ -56,6 +56,11 @@ fun WelcomeScreen(vm: AppViewModel) { .padding(horizontal = 24.dp, vertical = 16.dp), ) { Text("maturita.c", color = p.text, fontWeight = FontWeight.Bold, fontSize = 15.sp) + Text( + "sestavení ${org.maturita.maturita.BuildConfig.VERSION_NAME} · ${org.maturita.maturita.BuildConfig.COMMIT.take(7)}", + color = p.overlay, + fontSize = 12.sp, + ) Spacer(Modifier.height(12.dp)) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { @@ -184,7 +189,7 @@ fun SettingsSheet(vm: AppViewModel) { 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 id = ThemeId.entries.getOrNull(i) ?: return@forEachIndexed val sw = themePalette(id, vm.mode) val sel = vm.themeId == id Row(