mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Keep Android screens from crashing when opening subjects, stats, settings, or maps.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
aa1d8c4426
commit
db0422a952
5 changed files with 136 additions and 61 deletions
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue