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.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.MaterialTheme
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.LaunchedEffect
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.SubjectsScreen
import org.maturita.maturita.ui.UnitMapScreen
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.ui.VocabExercise
import org.maturita.maturita.ui.WelcomeScreen
@ -64,9 +65,43 @@ class MainActivity : ComponentActivity() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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
LaunchedEffect(Unit) { vm.checkUpdate(false) }
BackHandler(vm.canGoBack || vm.searchOpen || vm.settingsOpen) {
@ -184,11 +219,10 @@ fun MaturitaApp(vm: AppViewModel) {
}
if (vm.searchOpen) SearchOverlay(vm)
if (vm.settingsOpen) {
ModalBottomSheet(
onDismissRequest = { vm.settingsOpen = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = p.base,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
Box(
Modifier
.fillMaxSize()
.background(p.base),
) { SettingsSheet(vm) }
}
}

View file

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

View file

@ -126,6 +126,7 @@ fun PathMap(
nodes: List<MapNode>,
palette: Palette,
litUntil: Float,
modifier: Modifier = Modifier,
nodeSize: Float = 72f,
mx: Float = 90f,
my: Float = 110f,
@ -139,8 +140,8 @@ fun PathMap(
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()
BoxWithConstraints(modifier.fillMaxSize()) {
val avail = constraints.maxWidth.toFloat().coerceAtLeast(1f)
val geo = serpentine(nodes.size, avail, mx, my, spac, gap, wave, lift)
Box(
Modifier

View file

@ -7,10 +7,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.geometry.Offset
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
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
fun SubjectsScreen(vm: AppViewModel) {
val p = vm.palette
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
PageTop(vm, { vm.back() }, vm.tr("subjects_title"), vm.tr("subjects_sub"))
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) {
val cell = (maxWidth - 12.dp) / 2
FlowRow(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
maxItemsInEachRow = 2,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
vm.content.subjects.forEach { s ->
val open = s.bool("open")
Box(
Modifier
.width(cell)
.height(132.dp)
.clip(RoundedCornerShape(20.dp))
.background(p.mantle)
.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)
.then(if (open) Modifier.clickable { vm.goPage(s.str("target")) } else Modifier)
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
when (s.str("icon")) {
"de" -> GermanFlag(44.dp)
"wifi" -> WifiIcon(p.text, 36.dp)
"chip" -> ChipIcon(p.text, 36.dp)
"cz" -> CzechFlag(44.dp)
else -> LockIcon(p.overlay, 22.dp)
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
vm.content.subjects.chunked(2).forEach { row ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
row.forEach { s ->
val open = s.bool("open")
Box(
Modifier
.weight(1f)
.height(132.dp)
.clip(RoundedCornerShape(20.dp))
.background(p.mantle)
.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)
.then(if (open) Modifier.clickable { vm.goPage(s.str("target")) } else Modifier)
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
when (s.str("icon")) {
"de" -> GermanFlag(44.dp)
"wifi" -> WifiIcon(p.text, 36.dp)
"chip" -> ChipIcon(p.text, 36.dp)
"cz" -> CzechFlag(44.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())
.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))
Text(vm.tr("mode"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
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)
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
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)) {
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_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits), Modifier.weight(1f))
}
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))
Text(vm.tr("stats_section"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
@ -258,11 +264,7 @@ fun StatsScreen(vm: AppViewModel) {
)
if (open && part.totalEx > 0) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
part.doneEx / part.totalEx.toFloat(),
Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
p.accent, p.surface1, StrokeCap.Round,
)
ProgressBar(part.doneEx / part.totalEx.toFloat(), p, 6.dp)
}
}
}
@ -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
private fun Metric(p: org.maturita.maturita.data.Palette, label: String, value: String, modifier: Modifier) {
CardBox(p, modifier) {
@ -342,7 +362,7 @@ fun SearchOverlay(vm: AppViewModel) {
if (hits.isEmpty()) {
Text(if (q.isEmpty()) vm.tr("search_hint") else vm.tr("search_empty"), color = p.subtext)
} else {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
hits.forEach { hit ->
CardBox(p, Modifier.fillMaxWidth().clickable {
if (!hit.locked) {
@ -409,6 +429,7 @@ fun RoadmapScreen(vm: AppViewModel) {
Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
PageTop(vm, { vm.back() }, vm.tr("roadmap_title"), vm.tr("roadmap_sub"))
PathMap(
modifier = Modifier.weight(1f),
nodes = vm.content.german.mapIndexed { i, u ->
val unlocked = u.bool("unlocked")
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")))
val next = names.indices.firstOrNull { !vm.progress.germanDone(unitId, it + 1) }
PathMap(
modifier = Modifier.weight(1f),
nodes = names.mapIndexed { i, name ->
val n = i + 1
MapNode(
@ -503,7 +525,7 @@ fun LessonMapScreen(
) {
Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
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 apk = File(dir, "maturita.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 intent = Intent(Intent.ACTION_VIEW).apply {
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 {
val c = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000