Stop Android from crashing on subject maps and ship a new installable version.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-18 12:56:56 +02:00
commit cc36881bb9
6 changed files with 177 additions and 206 deletions

View file

@ -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

View file

@ -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<List<J>> = 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())
}
}
}
}

View file

@ -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),
)
}
}
}
}

View file

@ -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<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,
@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<Pair<NodePos, @Composable () -> Unit>> = emptyList(),
extraPoints: List<NodePos> = 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<NodePos>, index: Int = 1, dx: Float = 40f, lift: Float = 110f): NodePos? {
if (index !in points.indices) return null
val p = points[index]
return NodePos(p.x + dx, p.y - lift)
}

View file

@ -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(