Keep Android screens from crashing when opening subjects, stats, settings, or maps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-18 12:46:07 +02:00
commit db0422a952
5 changed files with 136 additions and 61 deletions

View file

@ -16,10 +16,10 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -51,6 +51,7 @@ import org.maturita.maturita.ui.StatsIcon
import org.maturita.maturita.ui.StatsScreen import org.maturita.maturita.ui.StatsScreen
import org.maturita.maturita.ui.SubjectsScreen import org.maturita.maturita.ui.SubjectsScreen
import org.maturita.maturita.ui.UnitMapScreen import org.maturita.maturita.ui.UnitMapScreen
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.ui.VocabExercise import org.maturita.maturita.ui.VocabExercise
import org.maturita.maturita.ui.WelcomeScreen import org.maturita.maturita.ui.WelcomeScreen
@ -64,9 +65,43 @@ class MainActivity : ComponentActivity() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MaturitaApp(vm: AppViewModel) { fun MaturitaApp(vm: AppViewModel) {
val p = vm.palette
val scheme = if (vm.mode == ColorMode.Dark) {
darkColorScheme(
primary = p.accent,
onPrimary = p.onAccent,
secondary = p.accent2,
background = p.base,
onBackground = p.text,
surface = p.mantle,
onSurface = p.text,
surfaceVariant = p.surface1,
onSurfaceVariant = p.subtext,
error = p.error,
)
} else {
lightColorScheme(
primary = p.accent,
onPrimary = p.onAccent,
secondary = p.accent2,
background = p.base,
onBackground = p.text,
surface = p.mantle,
onSurface = p.text,
surfaceVariant = p.surface1,
onSurfaceVariant = p.subtext,
error = p.error,
)
}
MaterialTheme(colorScheme = scheme) {
MaturitaAppBody(vm)
}
}
@Composable
private fun MaturitaAppBody(vm: AppViewModel) {
val p = vm.palette val p = vm.palette
LaunchedEffect(Unit) { vm.checkUpdate(false) } LaunchedEffect(Unit) { vm.checkUpdate(false) }
BackHandler(vm.canGoBack || vm.searchOpen || vm.settingsOpen) { BackHandler(vm.canGoBack || vm.searchOpen || vm.settingsOpen) {
@ -184,11 +219,10 @@ fun MaturitaApp(vm: AppViewModel) {
} }
if (vm.searchOpen) SearchOverlay(vm) if (vm.searchOpen) SearchOverlay(vm)
if (vm.settingsOpen) { if (vm.settingsOpen) {
ModalBottomSheet( Box(
onDismissRequest = { vm.settingsOpen = false }, Modifier
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), .fillMaxSize()
containerColor = p.base, .background(p.base),
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
) { SettingsSheet(vm) } ) { SettingsSheet(vm) }
} }
} }

View file

@ -23,6 +23,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem 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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@ -656,7 +657,9 @@ private fun ComboLine(
revealed: Boolean, revealed: Boolean,
inline: Boolean = false, inline: Boolean = false,
) { ) {
if (idx in expected.indices) expected[idx] = answer SideEffect {
if (idx in expected.indices && expected[idx] != answer) expected[idx] = answer
}
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 {
@ -963,6 +966,7 @@ private fun PrefixMenu(value: String, p: Palette, onPick: (String) -> Unit) {
fun LitQuizScreen(vm: AppViewModel, id: String) { fun LitQuizScreen(vm: AppViewModel, id: String) {
val book = vm.content.book(id) ?: return val book = vm.content.book(id) ?: return
val qs = book.arr("quiz") val qs = book.arr("quiz")
if (qs.isEmpty()) return
var idx by remember { mutableIntStateOf(0) } var idx by remember { mutableIntStateOf(0) }
var score by remember { mutableIntStateOf(0) } var score by remember { mutableIntStateOf(0) }
var answered by remember { mutableStateOf(false) } var answered by remember { mutableStateOf(false) }

View file

@ -126,6 +126,7 @@ fun PathMap(
nodes: List<MapNode>, nodes: List<MapNode>,
palette: Palette, palette: Palette,
litUntil: Float, litUntil: Float,
modifier: Modifier = Modifier,
nodeSize: Float = 72f, nodeSize: Float = 72f,
mx: Float = 90f, mx: Float = 90f,
my: Float = 110f, my: Float = 110f,
@ -139,8 +140,8 @@ fun PathMap(
if (nodes.isEmpty()) return if (nodes.isEmpty()) return
val density = LocalDensity.current val density = LocalDensity.current
fun Float.toDp(): androidx.compose.ui.unit.Dp = with(density) { this@toDp.toDp() } fun Float.toDp(): androidx.compose.ui.unit.Dp = with(density) { this@toDp.toDp() }
BoxWithConstraints(Modifier.fillMaxSize()) { BoxWithConstraints(modifier.fillMaxSize()) {
val avail = constraints.maxWidth.toFloat() val avail = constraints.maxWidth.toFloat().coerceAtLeast(1f)
val geo = serpentine(nodes.size, avail, mx, my, spac, gap, wave, lift) val geo = serpentine(nodes.size, avail, mx, my, spac, gap, wave, lift)
Box( Box(
Modifier Modifier

View file

@ -7,10 +7,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -22,7 +21,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LinearProgressIndicator
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
@ -31,7 +29,6 @@ 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.geometry.Offset
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
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.text.style.TextAlign
@ -108,28 +105,26 @@ private fun FeatureCard(vm: AppViewModel, t: String, b: String, l: String) {
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun SubjectsScreen(vm: AppViewModel) { fun SubjectsScreen(vm: AppViewModel) {
val p = vm.palette val p = vm.palette
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, vm.tr("subjects_title"), vm.tr("subjects_sub")) PageTop(vm, { vm.back() }, vm.tr("subjects_title"), vm.tr("subjects_sub"))
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) { Column(
val cell = (maxWidth - 12.dp) / 2
FlowRow(
Modifier Modifier
.fillMaxSize() .weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
maxItemsInEachRow = 2,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
vm.content.subjects.forEach { s -> vm.content.subjects.chunked(2).forEach { row ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
row.forEach { s ->
val open = s.bool("open") val open = s.bool("open")
Box( Box(
Modifier Modifier
.width(cell) .weight(1f)
.height(132.dp) .height(132.dp)
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
.background(p.mantle) .background(p.mantle)
@ -152,6 +147,8 @@ fun SubjectsScreen(vm: AppViewModel) {
} }
} }
} }
if (row.size == 1) Spacer(Modifier.weight(1f))
}
} }
} }
} }
@ -166,7 +163,16 @@ fun SettingsSheet(vm: AppViewModel) {
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(20.dp), .padding(20.dp),
) { ) {
Text(vm.tr("settings_title"), color = p.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) Row(verticalAlignment = Alignment.CenterVertically) {
Text(
vm.tr("settings_title"),
color = p.text,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
PillButton(vm.tr("back"), p) { vm.settingsOpen = false }
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text(vm.tr("mode"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) Text(vm.tr("mode"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
@ -233,14 +239,14 @@ fun StatsScreen(vm: AppViewModel) {
val pct = if (sum.totalEx == 0) 0 else (100 * sum.doneEx / sum.totalEx) val pct = if (sum.totalEx == 0) 0 else (100 * sum.doneEx / sum.totalEx)
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, vm.tr("stats_title"), vm.tr("stats_sub")) PageTop(vm, { vm.back() }, vm.tr("stats_title"), vm.tr("stats_sub"))
Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.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_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_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)) Metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits), Modifier.weight(1f))
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
LinearProgressIndicator(pct / 100f, Modifier.fillMaxWidth().height(8.dp).clip(CircleShape), p.accent, p.surface1, StrokeCap.Round) ProgressBar(pct / 100f, p, 8.dp)
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text(vm.tr("stats_section"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) Text(vm.tr("stats_section"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -258,17 +264,31 @@ fun StatsScreen(vm: AppViewModel) {
) )
if (open && part.totalEx > 0) { if (open && part.totalEx > 0) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
LinearProgressIndicator( ProgressBar(part.doneEx / part.totalEx.toFloat(), p, 6.dp)
part.doneEx / part.totalEx.toFloat(), }
Modifier.fillMaxWidth().height(6.dp).clip(CircleShape), }
p.accent, p.surface1, StrokeCap.Round, }
}
}
}
}
@Composable
private fun ProgressBar(fraction: Float, p: org.maturita.maturita.data.Palette, barHeight: androidx.compose.ui.unit.Dp) {
Box(
Modifier
.fillMaxWidth()
.height(barHeight)
.clip(CircleShape)
.background(p.surface1),
) {
Box(
Modifier
.fillMaxHeight()
.fillMaxWidth(fraction.coerceIn(0f, 1f))
.background(p.accent),
) )
} }
}
}
}
}
}
} }
@Composable @Composable
@ -342,7 +362,7 @@ fun SearchOverlay(vm: AppViewModel) {
if (hits.isEmpty()) { if (hits.isEmpty()) {
Text(if (q.isEmpty()) vm.tr("search_hint") else vm.tr("search_empty"), color = p.subtext) Text(if (q.isEmpty()) vm.tr("search_hint") else vm.tr("search_empty"), color = p.subtext)
} else { } else {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
hits.forEach { hit -> hits.forEach { hit ->
CardBox(p, Modifier.fillMaxWidth().clickable { CardBox(p, Modifier.fillMaxWidth().clickable {
if (!hit.locked) { if (!hit.locked) {
@ -409,6 +429,7 @@ fun RoadmapScreen(vm: AppViewModel) {
Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
PageTop(vm, { vm.back() }, vm.tr("roadmap_title"), vm.tr("roadmap_sub")) PageTop(vm, { vm.back() }, vm.tr("roadmap_title"), vm.tr("roadmap_sub"))
PathMap( PathMap(
modifier = Modifier.weight(1f),
nodes = vm.content.german.mapIndexed { i, u -> nodes = vm.content.german.mapIndexed { i, u ->
val unlocked = u.bool("unlocked") val unlocked = u.bool("unlocked")
val names = u.strs("names") val names = u.strs("names")
@ -438,6 +459,7 @@ fun UnitMapScreen(vm: AppViewModel, unitId: Int) {
PageTop(vm, { vm.back() }, u.str("title"), vm.tr(u.str("sub"))) PageTop(vm, { vm.back() }, u.str("title"), vm.tr(u.str("sub")))
val next = names.indices.firstOrNull { !vm.progress.germanDone(unitId, it + 1) } val next = names.indices.firstOrNull { !vm.progress.germanDone(unitId, it + 1) }
PathMap( PathMap(
modifier = Modifier.weight(1f),
nodes = names.mapIndexed { i, name -> nodes = names.mapIndexed { i, name ->
val n = i + 1 val n = i + 1
MapNode( MapNode(
@ -503,7 +525,7 @@ fun LessonMapScreen(
) { ) {
Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
PageTop(vm, { vm.back() }, title, sub) PageTop(vm, { vm.back() }, title, sub)
PathMap(nodes, vm.palette, litUntil, nodeSize = 64f, spac = 150f, mx = 70f) PathMap(nodes, vm.palette, litUntil, modifier = Modifier.weight(1f), nodeSize = 64f, spac = 150f, mx = 70f)
} }
} }

View file

@ -66,6 +66,11 @@ class Updater(private val app: Application) {
val dir = File(app.cacheDir, "updates").apply { mkdirs() } val dir = File(app.cacheDir, "updates").apply { mkdirs() }
val apk = File(dir, "maturita.apk") val apk = File(dir, "maturita.apk")
download(url, apk) download(url, apk)
if (!isApk(apk)) {
apk.delete()
emit(cb, UpdateState("error", messageKey = "update_err_download", messageArg = null))
return@execute
}
val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", apk) val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", apk)
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
@ -79,6 +84,15 @@ class Updater(private val app: Application) {
} }
} }
private fun isApk(file: File): Boolean {
if (file.length() < 1_000_000L) return false
file.inputStream().use { input ->
val header = ByteArray(2)
if (input.read(header) != 2) return false
return header[0] == 'P'.code.toByte() && header[1] == 'K'.code.toByte()
}
}
private fun get(url: String): String { private fun get(url: String): String {
val c = (URL(url).openConnection() as HttpURLConnection).apply { val c = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000 connectTimeout = 15000