diff --git a/android/app/src/main/java/org/maturita/maturita/MainActivity.kt b/android/app/src/main/java/org/maturita/maturita/MainActivity.kt index b0836e1..4198d25 100644 --- a/android/app/src/main/java/org/maturita/maturita/MainActivity.kt +++ b/android/app/src/main/java/org/maturita/maturita/MainActivity.kt @@ -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) } } } diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt b/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt index 912a2a3..dfed1f2 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt @@ -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) } diff --git a/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt b/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt index d2a8264..a0c57c6 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt @@ -126,6 +126,7 @@ fun PathMap( nodes: List, 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 diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt b/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt index 16c82e9..710b0fa 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt @@ -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) } } diff --git a/android/app/src/main/java/org/maturita/maturita/update/Updater.kt b/android/app/src/main/java/org/maturita/maturita/update/Updater.kt index 74205ab..dad9196 100644 --- a/android/app/src/main/java/org/maturita/maturita/update/Updater.kt +++ b/android/app/src/main/java/org/maturita/maturita/update/Updater.kt @@ -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