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,49 +105,49 @@ 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 Modifier
FlowRow( .weight(1f)
Modifier .fillMaxWidth()
.fillMaxSize() .verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()) .padding(bottom = 24.dp),
.padding(bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
maxItemsInEachRow = 2, ) {
horizontalArrangement = Arrangement.spacedBy(12.dp), vm.content.subjects.chunked(2).forEach { row ->
verticalArrangement = Arrangement.spacedBy(12.dp), Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
) { row.forEach { s ->
vm.content.subjects.forEach { s -> val open = s.bool("open")
val open = s.bool("open") Box(
Box( Modifier
Modifier .weight(1f)
.width(cell) .height(132.dp)
.height(132.dp) .clip(RoundedCornerShape(20.dp))
.clip(RoundedCornerShape(20.dp)) .background(p.mantle)
.background(p.mantle) .border(1.dp, if (open) p.text.copy(0.08f) else p.text.copy(0.04f), RoundedCornerShape(20.dp))
.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)
.alpha(if (open) 1f else 0.48f) .then(if (open) Modifier.clickable { vm.goPage(s.str("target")) } else Modifier)
.then(if (open) Modifier.clickable { vm.goPage(s.str("target")) } else Modifier) .padding(16.dp),
.padding(16.dp), contentAlignment = Alignment.Center,
contentAlignment = Alignment.Center, ) {
) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { when (s.str("icon")) {
when (s.str("icon")) { "de" -> GermanFlag(44.dp)
"de" -> GermanFlag(44.dp) "wifi" -> WifiIcon(p.text, 36.dp)
"wifi" -> WifiIcon(p.text, 36.dp) "chip" -> ChipIcon(p.text, 36.dp)
"chip" -> ChipIcon(p.text, 36.dp) "cz" -> CzechFlag(44.dp)
"cz" -> CzechFlag(44.dp) else -> LockIcon(p.overlay, 22.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)
} }
Spacer(Modifier.height(10.dp))
Text(vm.tr(s.str("key")), color = p.text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center)
} }
} }
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,11 +264,7 @@ 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,
)
} }
} }
} }
@ -271,6 +273,24 @@ fun StatsScreen(vm: AppViewModel) {
} }
} }
@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
private fun Metric(p: org.maturita.maturita.data.Palette, label: String, value: String, modifier: Modifier) { private fun Metric(p: org.maturita.maturita.data.Palette, label: String, value: String, modifier: Modifier) {
CardBox(p, modifier) { CardBox(p, modifier) {
@ -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