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

@ -13,8 +13,8 @@ android {
applicationId = "org.maturita.maturita" applicationId = "org.maturita.maturita"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 3
versionName = "android" versionName = "android-3"
buildConfigField("String", "COMMIT", "\"$commit\"") buildConfigField("String", "COMMIT", "\"$commit\"")
buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"") buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"")
buildConfigField("String", "UPDATE_BRANCH", "\"builds\"") buildConfigField("String", "UPDATE_BRANCH", "\"builds\"")

View file

@ -1,6 +1,12 @@
package org.maturita.maturita package org.maturita.maturita
import android.os.Bundle 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.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@ -60,9 +66,61 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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() enableEdgeToEdge()
setContent { MaturitaApp(vm) } 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 @Composable

View file

@ -16,7 +16,7 @@ class Content(root: JSONObject) {
} }
val subjects = raw.arr("subjects") val subjects = raw.arr("subjects")
val german = raw.arr("german") val german = raw.arr("german")
val net = raw.obj("net")!! val net = raw.obj("net") ?: J(JSONObject())
val netLessons = net.arr("lessons") val netLessons = net.arr("lessons")
val netTasks = net.arr("tasks") val netTasks = net.arr("tasks")
val netAnswers: List<List<J>> = run { val netAnswers: List<List<J>> = run {
@ -59,8 +59,12 @@ class Content(root: JSONObject) {
companion object { companion object {
fun load(context: Context): Content { fun load(context: Context): Content {
val text = context.assets.open("content.json").bufferedReader().use { it.readText() } return try {
return Content(JSONObject(text)) 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect 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)) { 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) if (placed[i].isEmpty()) Text("…", color = p.overlay)
placed[i].forEach { idx -> 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) placed[i].remove(idx)
pools[i].add(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)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
pools[i].toList().forEach { idx -> 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) pools[i].remove(idx)
placed[i].add(idx) placed[i].add(idx)
} }
@ -663,26 +664,43 @@ private fun ComboLine(
val p = vm.palette val p = vm.palette
var open by remember { mutableStateOf(false) } var open by remember { mutableStateOf(false) }
val box = @Composable { val box = @Composable {
Box { Column {
Box( Box(
Modifier Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(p.mantle) .background(p.mantle)
.border(1.dp, if (revealed && picks.getOrNull(idx).orEmpty().isNotEmpty()) { .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)) } else p.text.copy(0.1f), RoundedCornerShape(12.dp))
.clickable { open = true } .clickable { open = !open }
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
) { ) {
val pick = picks.getOrNull(idx).orEmpty() val pick = picks.getOrNull(idx).orEmpty()
Text(pick.ifEmpty { "—" }, color = if (pick.isEmpty()) p.overlay else p.text) Text(pick.ifEmpty { "—" }, color = if (pick.isEmpty()) p.overlay else p.text)
} }
DropdownMenu(open, { open = false }) { if (open) {
pool.forEach { w -> Column(
DropdownMenuItem({ Text(w) }, { Modifier
if (idx in picks.indices) picks[idx] = w .padding(top = 4.dp)
open = false .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 @Composable
private fun PrefixMenu(value: String, p: Palette, onPick: (String) -> Unit) { private fun PrefixMenu(value: String, p: Palette, onPick: (String) -> Unit) {
var open by remember { mutableStateOf(false) } var open by remember { mutableStateOf(false) }
Box(Modifier.padding(vertical = 4.dp)) { Column(Modifier.padding(vertical = 4.dp)) {
Box( 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) } ) { Text(value.ifEmpty { "/…" }, color = p.text) }
DropdownMenu(open, { open = false }) { if (open) {
(8..30).forEach { n -> Column(
DropdownMenuItem({ Text("/$n") }, { onPick("/$n"); open = false }) 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 package org.maturita.maturita.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip 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.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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.maturita.maturita.data.Palette import org.maturita.maturita.data.Palette
import kotlin.math.PI
import kotlin.math.min
import kotlin.math.sin
data class MapNode( data class MapNode(
val id: String, val id: String,
@ -47,191 +36,73 @@ data class MapNode(
val onClick: (() -> Unit)? = null, 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 @Composable
fun PathMap( fun PathMap(
nodes: List<MapNode>, nodes: List<MapNode>,
palette: Palette, palette: Palette,
litUntil: Float, @Suppress("UNUSED_PARAMETER") litUntil: Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
nodeSize: Float = 72f, @Suppress("UNUSED_PARAMETER") nodeSize: Float = 72f,
mx: Float = 90f, @Suppress("UNUSED_PARAMETER") mx: Float = 90f,
my: Float = 110f, @Suppress("UNUSED_PARAMETER") my: Float = 110f,
spac: Float = 170f, @Suppress("UNUSED_PARAMETER") spac: Float = 170f,
gap: Float = 170f, @Suppress("UNUSED_PARAMETER") gap: Float = 170f,
wave: Float = 28f, @Suppress("UNUSED_PARAMETER") wave: Float = 28f,
lift: Float = 0f, @Suppress("UNUSED_PARAMETER") lift: Float = 0f,
extra: List<Pair<NodePos, @Composable () -> Unit>> = emptyList(),
extraPoints: List<NodePos> = emptyList(),
) { ) {
if (nodes.isEmpty()) return Column(
val density = LocalDensity.current modifier
fun Float.toDp(): androidx.compose.ui.unit.Dp = with(density) { this@toDp.toDp() } .fillMaxSize()
BoxWithConstraints(modifier.fillMaxSize()) { .verticalScroll(rememberScrollState())
val avail = constraints.maxWidth.toFloat().coerceAtLeast(1f) .padding(bottom = 24.dp),
val geo = serpentine(nodes.size, avail, mx, my, spac, gap, wave, lift) verticalArrangement = Arrangement.spacedBy(10.dp),
Box( ) {
Modifier nodes.forEachIndexed { i, node ->
.horizontalScroll(rememberScrollState()) val click = node.onClick
.verticalScroll(rememberScrollState()), CardBox(
) { palette,
Box(Modifier.width(geo.width.toDp()).height((geo.height + 80f).toDp())) { Modifier
Canvas(Modifier.fillMaxSize()) { .fillMaxWidth()
if (geo.points.size < 2) return@Canvas .alpha(if (node.locked) 0.48f else 1f)
fun pathBetween(t0: Float, t1: Float): Path { .then(
val p = Path() if (!node.locked && click != null) Modifier.clickable { click() }
val steps = maxOf(1, ((t1 - t0) * 48).toInt()) else Modifier,
val start = catmull(geo.points, t0) ),
p.moveTo(start.x, start.y) ) {
for (s in 1..steps) { Row(verticalAlignment = Alignment.CenterVertically) {
val t = t0 + (t1 - t0) * s / steps val bg = when {
val pt = catmull(geo.points, t) node.locked -> palette.lockedBg
p.lineTo(pt.x, pt.y) node.current || node.done -> palette.accent
} else -> palette.node
return p
} }
val end = (nodes.size - 1).toFloat() val fg = when {
drawPath(pathBetween(0f, end), palette.rail, style = Stroke(12f, cap = StrokeCap.Round, join = StrokeJoin.Round)) node.locked -> palette.overlay
if (litUntil > 0f) { node.current || node.done -> palette.onAccent
drawPath(pathBetween(0f, litUntil), palette.accent, style = Stroke(12f, cap = StrokeCap.Round, join = StrokeJoin.Round)) else -> palette.text
} }
extraPoints.forEach { }
}
nodes.forEachIndexed { i, node ->
val pt = geo.points[i]
Box( Box(
Modifier Modifier
.offset { IntOffset((pt.x - nodeSize / 2).toInt(), (pt.y - nodeSize / 2).toInt()) } .size(44.dp)
.size(nodeSize.toDp()), .clip(CircleShape)
.background(bg),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val bg = when { when {
node.locked -> palette.lockedBg node.locked -> LockIcon(palette.overlay, 16.dp)
node.current || node.done -> palette.accent node.done -> CheckIcon(palette.onAccent, 16.dp)
else -> palette.node else -> Text("${i + 1}", color = fg, fontWeight = FontWeight.Black, fontSize = 16.sp)
}
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)
}
}
} }
} }
Spacer(Modifier.width(12.dp))
Text( Text(
node.label, node.label,
color = if (node.locked) palette.overlay else palette.text, color = if (node.locked) palette.overlay else palette.text,
fontSize = 12.sp, fontWeight = if (node.current) FontWeight.Bold else FontWeight.SemiBold,
fontWeight = FontWeight.SemiBold, fontSize = 16.sp,
textAlign = TextAlign.Center, modifier = Modifier.weight(1f),
modifier = Modifier
.offset { IntOffset((pt.x - 70).toInt(), (pt.y + nodeSize / 2 + 8).toInt()) }
.width(140.dp),
) )
} }
extra.forEach { (pt, content) ->
Box(Modifier.offset { IntOffset(pt.x.toInt(), pt.y.toInt()) }) { content() }
}
} }
} }
} }
} }
fun branchPoint(points: List<NodePos>, index: Int = 1, dx: Float = 40f, lift: Float = 110f): NodePos? {
if (index !in points.indices) return null
val p = points[index]
return NodePos(p.x + dx, p.y - lift)
}

View file

@ -56,6 +56,11 @@ fun WelcomeScreen(vm: AppViewModel) {
.padding(horizontal = 24.dp, vertical = 16.dp), .padding(horizontal = 24.dp, vertical = 16.dp),
) { ) {
Text("maturita.c", color = p.text, fontWeight = FontWeight.Bold, fontSize = 15.sp) 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)) Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { 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) Text(vm.tr("theme"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
themeNames.forEachIndexed { i, name -> themeNames.forEachIndexed { i, name ->
val id = ThemeId.entries[i] val id = ThemeId.entries.getOrNull(i) ?: return@forEachIndexed
val sw = themePalette(id, vm.mode) val sw = themePalette(id, vm.mode)
val sel = vm.themeId == id val sel = vm.themeId == id
Row( Row(