Polish the UI on all platforms with progress and a two-column code editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-11 16:31:12 +02:00
commit 0047bd74f0
9 changed files with 1288 additions and 594 deletions

View file

@ -40,6 +40,8 @@ fun main() {
val info = Properties().apply { resourceText("build-info.properties")?.let { load(it.reader()) } } val info = Properties().apply { resourceText("build-info.properties")?.let { load(it.reader()) } }
val platform = DesktopPlatform(info.getProperty("commit") ?: "dev", prefs, install) val platform = DesktopPlatform(info.getProperty("commit") ?: "dev", prefs, install)
val vm = AppViewModel(Content.parse(resourceText("content.json")), platform) val vm = AppViewModel(Content.parse(resourceText("content.json")), platform)
// `GRADUATELY_PAGE=u1e3 make run` opens a page, for screenshots.
System.getenv("GRADUATELY_PAGE")?.takeIf { it.isNotBlank() }?.let { vm.openPage(it) }
val icon = Thread.currentThread().contextClassLoader.getResourceAsStream("icon.png") val icon = Thread.currentThread().contextClassLoader.getResourceAsStream("icon.png")
?.use { ImageIO.read(it) }?.let { BitmapPainter(it.toComposeImageBitmap()) } ?.use { ImageIO.read(it) }?.let { BitmapPainter(it.toComposeImageBitmap()) }

View file

@ -1,32 +1,59 @@
package org.maturita.maturita.ui package org.maturita.maturita.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight 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
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
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.FilledTonalButton import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.maturita.maturita.AppViewModel import org.maturita.maturita.AppViewModel
import org.maturita.maturita.data.J import org.maturita.maturita.data.J
@ -34,18 +61,21 @@ import org.maturita.maturita.data.J
/** Explanation on one side, the program and its output on the other. */ /** Explanation on one side, the program and its output on the other. */
@Composable @Composable
fun CodeLessonScreen(vm: AppViewModel, lesson: J, onSuccess: () -> Unit) { fun CodeLessonScreen(vm: AppViewModel, lesson: J, onSuccess: () -> Unit) {
val key = lesson.str("key")
val starter = lesson.str("code") val starter = lesson.str("code")
var code by remember(lesson.str("key")) { mutableStateOf(starter) } var code by remember(key) { mutableStateOf(starter) }
var stdin by remember(lesson.str("key")) { mutableStateOf("") } var stdin by remember(key) { mutableStateOf("") }
var output by remember(lesson.str("key")) { mutableStateOf("") } var output by remember(key) { mutableStateOf("") }
var running by remember(lesson.str("key")) { mutableStateOf(false) } var status by remember(key) { mutableStateOf("") }
val running = status == "running"
val needsInput = "ReadLine" in code
fun run() { fun run() {
if (running) return if (running) return
running = true status = "running"
output = vm.tr("cs_running") output = vm.tr("cs_running")
vm.platform.runCSharp(code, stdin) { result -> vm.platform.runCSharp(code, if (needsInput) stdin else "") { result ->
running = false status = result.status
output = when (result.status) { output = when (result.status) {
"unavailable" -> vm.tr("cs_unavailable") "unavailable" -> vm.tr("cs_unavailable")
"timeout" -> (result.output + "\n\n" + vm.tr("cs_timeout")).trim() "timeout" -> (result.output + "\n\n" + vm.tr("cs_timeout")).trim()
@ -55,19 +85,39 @@ fun CodeLessonScreen(vm: AppViewModel, lesson: J, onSuccess: () -> Unit) {
} }
} }
DetailScaffold(vm, vm.tr(lesson.str("titleKey")), vm.tr(lesson.str("subKey"))) { inner -> fun reset() {
BoxWithConstraints(Modifier.fillMaxSize().padding(inner).padding(12.dp)) { code = starter
if (maxWidth >= 840.dp) { output = ""
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { status = ""
Explanation(vm, lesson, Modifier.weight(0.9f).fillMaxHeight()) }
EditorColumn(vm, code, { code = it }, stdin, { stdin = it }, expand = true, Modifier.weight(1.15f).fillMaxHeight())
OutputColumn(vm, output, running, { run() }, { code = starter }, expand = true, Modifier.weight(1f).fillMaxHeight()) DetailScaffold(vm, vm.tr(lesson.str("titleKey")), vm.tr(lesson.str("subKey")), maxContentWidth = 1640.dp) { inner ->
BoxWithConstraints(Modifier.fillMaxSize().padding(inner)) {
val width = maxWidth
val height = maxHeight
if (width >= 900.dp) {
Row(
Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
Column(Modifier.width(min(560.dp, width * 0.4f)).fillMaxHeight().verticalScroll(rememberScrollState())) {
Explanation(lesson)
}
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
EditorCard(vm, code, { code = it }, running, ::run, ::reset, compact = false, Modifier.weight(1f))
if (needsInput) InputField(vm, stdin) { stdin = it }
OutputCard(vm, output, status, Modifier.height(max(180.dp, height * 0.32f)))
}
} }
} else { } else {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(
Explanation(vm, lesson, Modifier.fillMaxWidth()) Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
EditorColumn(vm, code, { code = it }, stdin, { stdin = it }, expand = false, Modifier.fillMaxWidth()) verticalArrangement = Arrangement.spacedBy(16.dp),
OutputColumn(vm, output, running, { run() }, { code = starter }, expand = false, Modifier.fillMaxWidth()) ) {
Explanation(lesson)
EditorCard(vm, code, { code = it }, running, ::run, ::reset, compact = true, Modifier.height(320.dp))
if (needsInput) InputField(vm, stdin) { stdin = it }
OutputCard(vm, output, status, Modifier.height(220.dp))
} }
} }
} }
@ -75,84 +125,179 @@ fun CodeLessonScreen(vm: AppViewModel, lesson: J, onSuccess: () -> Unit) {
} }
@Composable @Composable
private fun Explanation(vm: AppViewModel, lesson: J, modifier: Modifier) { private fun Explanation(lesson: J) {
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(28.dp)) {
lesson.arr("slides").forEach { slide -> lesson.arr("slides").forEach { SlideContent(it) }
Text(slide.str("kicker"), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge)
Text(slide.str("title"), style = MaterialTheme.typography.titleLarge)
slide.strs("lines").forEach { line ->
Text("• $line", style = MaterialTheme.typography.bodyLarge)
}
slide.strOrNull("tip")?.let { tip ->
Text(tip, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
}
}
} }
} }
@Composable @Composable
private fun EditorColumn( private fun EditorCard(
vm: AppViewModel, vm: AppViewModel,
code: String, code: String,
onCode: (String) -> Unit, onCode: (String) -> Unit,
stdin: String, running: Boolean,
onStdin: (String) -> Unit, onRun: () -> Unit,
expand: Boolean, onReset: () -> Unit,
compact: Boolean,
modifier: Modifier, modifier: Modifier,
) { ) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
Text(vm.tr("cs_code"), style = MaterialTheme.typography.titleMedium) val highlight = remember(dark) { CSharpHighlight(dark) }
BasicTextField( Surface(modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh) {
value = code, Column {
onValueChange = onCode, Row(
textStyle = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface), Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), verticalAlignment = Alignment.CenterVertically,
modifier = Modifier horizontalArrangement = Arrangement.spacedBy(10.dp),
.then(if (expand) Modifier.weight(1f) else Modifier.height(240.dp)) ) {
.fillMaxWidth() Icon(Icons.Filled.Code, null, tint = MaterialTheme.colorScheme.primary)
.background(MaterialTheme.colorScheme.surfaceContainer) Text("Program.cs", style = MaterialTheme.typography.titleSmall.copy(fontFamily = FontFamily.Monospace))
.padding(12.dp) Spacer(Modifier.weight(1f))
.verticalScroll(rememberScrollState()), if (compact) {
) IconButton(onClick = onReset, enabled = !running) {
Icon(Icons.Filled.Refresh, contentDescription = vm.tr("cs_reset"))
}
} else {
OutlinedButton(onClick = onReset, enabled = !running) {
Icon(Icons.Filled.Refresh, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(vm.tr("cs_reset"))
}
}
Button(onClick = onRun, enabled = !running) {
Icon(Icons.Filled.PlayArrow, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(vm.tr("cs_run"))
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
BasicTextField(
value = code,
onValueChange = onCode,
textStyle = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = highlight,
modifier = Modifier.fillMaxSize().padding(14.dp),
)
}
}
}
@Composable
private fun InputField(vm: AppViewModel, stdin: String, onStdin: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(vm.tr("cs_stdin"), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(vm.tr("cs_stdin"), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
BasicTextField( OutlinedTextField(
value = stdin, value = stdin,
onValueChange = onStdin, onValueChange = onStdin,
textStyle = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface), textStyle = codeStyle(),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), minLines = 1,
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainer).padding(12.dp), maxLines = 4,
modifier = Modifier.fillMaxWidth(),
) )
} }
} }
@Composable @Composable
private fun OutputColumn( private fun OutputCard(vm: AppViewModel, output: String, status: String, modifier: Modifier) {
vm: AppViewModel, val muted = output.isEmpty() || status == "running"
output: String, Surface(modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh) {
running: Boolean, Column {
onRun: () -> Unit, Row(
onReset: () -> Unit, Modifier.fillMaxWidth().height(48.dp).padding(horizontal = 14.dp),
expand: Boolean, verticalAlignment = Alignment.CenterVertically,
modifier: Modifier, ) {
) { Icon(Icons.Filled.Terminal, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { Spacer(Modifier.width(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text(vm.tr("cs_output"), style = MaterialTheme.typography.titleSmall)
FilledTonalButton(onClick = onRun, enabled = !running) { Text(vm.tr("cs_run")) } Spacer(Modifier.weight(1f))
OutlinedButton(onClick = onReset, enabled = !running) { Text(vm.tr("cs_reset")) } StatusBadge(vm, status)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Text(
output.ifEmpty { vm.tr("cs_empty") },
style = codeStyle(),
color = if (muted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp),
)
} }
Text(vm.tr("cs_output"), style = MaterialTheme.typography.titleMedium)
Text(
output.ifEmpty { vm.tr("cs_empty") },
style = codeStyle(),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.then(if (expand) Modifier.weight(1f) else Modifier.height(180.dp))
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(12.dp)
.verticalScroll(rememberScrollState()),
)
} }
} }
private fun codeStyle() = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp, lineHeight = 20.sp) @Composable
private fun StatusBadge(vm: AppViewModel, status: String) {
when (status) {
"running" -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
"ok" -> Badge(vm.tr("cs_done"), Icons.Filled.CheckCircle, MaterialTheme.colorScheme.primary)
"fail", "timeout" -> Badge(vm.tr("cs_failed"), Icons.Filled.Cancel, MaterialTheme.colorScheme.error)
}
}
@Composable
private fun Badge(text: String, icon: androidx.compose.ui.graphics.vector.ImageVector, color: Color) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = color, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(text, style = MaterialTheme.typography.labelLarge, color = color)
}
}
private fun codeStyle() = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 15.sp, lineHeight = 22.sp)
/** Colours C# keywords, types, numbers, strings and comments without changing the text. */
private class CSharpHighlight(dark: Boolean) : VisualTransformation {
private val palette = listOf(
Color(if (dark) 0xFFFF7AB2 else 0xFF9B2393),
Color(if (dark) 0xFF5DD8FF else 0xFF0B4F79),
Color(if (dark) 0xFFD9C97C else 0xFF1C00CF),
Color(if (dark) 0xFFFC6A5D else 0xFFC41A16),
Color(if (dark) 0xFF7F8C98 else 0xFF5D6C79),
)
override fun filter(text: AnnotatedString): TransformedText =
TransformedText(highlight(text.text), OffsetMapping.Identity)
private fun highlight(code: String): AnnotatedString {
val kind = IntArray(code.length) { -1 }
RULES.forEachIndexed { index, rule ->
rule.findAll(code).forEach { m -> for (i in m.range) kind[i] = index }
}
return buildAnnotatedString {
var start = 0
while (start < code.length) {
var end = start
while (end < code.length && kind[end] == kind[start]) end++
val k = kind[start]
if (k >= 0) {
pushStyle(SpanStyle(color = palette[k]))
append(code, start, end)
pop()
} else {
append(code, start, end)
}
start = end
}
}
}
companion object {
private val KEYWORDS = listOf(
"abstract", "as", "async", "await", "base", "bool", "break", "case", "catch", "char", "class",
"const", "continue", "decimal", "default", "do", "double", "else", "enum", "false", "finally",
"float", "for", "foreach", "get", "if", "in", "int", "interface", "internal", "is", "long",
"namespace", "new", "null", "object", "out", "override", "private", "protected", "public",
"readonly", "record", "ref", "return", "sealed", "set", "static", "string", "struct", "switch",
"this", "throw", "true", "try", "using", "var", "virtual", "void", "while",
)
/** Later rules win, so strings and comments override keywords inside them. */
private val RULES = listOf(
Regex("\\b(?:" + KEYWORDS.joinToString("|") + ")\\b"),
Regex("\\b(?:Console|Math|List|Dictionary|File|Task|Exception|Func|Action|String|DateTime|Random)\\b"),
Regex("\\b\\d+(?:\\.\\d+)?\\b"),
Regex("\\$?@?\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])'"),
Regex("//[^\\n]*|/\\*[\\s\\S]*?\\*/"),
)
}
}

View file

@ -8,15 +8,22 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
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
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
@ -24,7 +31,17 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@ -44,7 +61,6 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -150,7 +166,19 @@ fun AppNavBar(vm: AppViewModel) {
private fun navIconColor(selected: Boolean): Color = private fun navIconColor(selected: Boolean): Color =
if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
/** Tab root: large collapsing title, search action, bottom navigation. */ /** Lists and exercises stop growing here so they keep their shape in wide desktop windows. */
val ContentMaxWidth = 880.dp
val ActionMaxWidth = 560.dp
/** Caps the width of [content] and centres it; phones are narrower than the cap anyway. */
@Composable
fun CenteredColumn(maxWidth: Dp = ContentMaxWidth, content: @Composable () -> Unit) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Box(Modifier.widthIn(max = maxWidth).fillMaxSize()) { content() }
}
}
/** Tab root: large collapsing title on phones, a compact one in wide windows. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RootScaffold( fun RootScaffold(
@ -159,30 +187,48 @@ fun RootScaffold(
showSearch: Boolean = true, showSearch: Boolean = true,
content: LazyListScope.() -> Unit, content: LazyListScope.() -> Unit,
) { ) {
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) BoxWithConstraints {
Scaffold( val wide = maxWidth >= 600.dp
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection), val scroll = if (wide) {
topBar = { TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
LargeTopAppBar( } else {
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
actions = { }
if (showSearch) { val actions: @Composable RowScope.() -> Unit = {
IconButton(onClick = { vm.searchOpen = true }) { if (showSearch) {
Icon(Icons.Filled.Search, contentDescription = vm.tr("search_placeholder")) IconButton(onClick = { vm.searchOpen = true }) {
} Icon(Icons.Filled.Search, contentDescription = vm.tr("search_placeholder"))
} }
}, }
scrollBehavior = scroll, }
) Scaffold(
}, modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
bottomBar = { AppNavBar(vm) }, topBar = {
) { inner -> if (wide) {
LazyColumn( TopAppBar(
contentPadding = PaddingValues( title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineSmall) },
top = inner.calculateTopPadding(), actions = actions,
bottom = inner.calculateBottomPadding() + 16.dp, scrollBehavior = scroll,
), )
) { content() } } else {
LargeTopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
actions = actions,
scrollBehavior = scroll,
)
}
},
bottomBar = { AppNavBar(vm) },
) { inner ->
CenteredColumn {
LazyColumn(
contentPadding = PaddingValues(
top = inner.calculateTopPadding(),
bottom = inner.calculateBottomPadding() + 16.dp,
),
) { content() }
}
}
} }
} }
@ -194,6 +240,7 @@ fun DetailScaffold(
title: String, title: String,
subtitle: String? = null, subtitle: String? = null,
bottomBar: (@Composable () -> Unit)? = null, bottomBar: (@Composable () -> Unit)? = null,
maxContentWidth: Dp = ContentMaxWidth,
content: @Composable (PaddingValues) -> Unit, content: @Composable (PaddingValues) -> Unit,
) { ) {
val scroll = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scroll = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@ -224,8 +271,9 @@ fun DetailScaffold(
) )
}, },
bottomBar = { bottomBar?.invoke() }, bottomBar = { bottomBar?.invoke() },
content = content, ) { inner ->
) CenteredColumn(maxContentWidth) { content(inner) }
}
} }
/** Feedback line plus the primary action, anchored at the bottom of an exercise. */ /** Feedback line plus the primary action, anchored at the bottom of an exercise. */
@ -242,6 +290,8 @@ fun ActionBar(
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.widthIn(max = ActionMaxWidth)
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
) { ) {
@ -276,6 +326,27 @@ fun SectionHeader(text: String) {
) )
} }
/** Number or short label in a tinted square, so rows line up whatever the label's width. */
@Composable
fun LeadingBadge(text: String) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center,
) {
Text(text, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
}
}
@Composable
fun LeadingIcon(icon: ImageVector) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center,
) {
Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
}
/** One navigable row: done/pending/locked state on the left, chevron on the right. */ /** One navigable row: done/pending/locked state on the left, chevron on the right. */
@Composable @Composable
fun NavRow( fun NavRow(
@ -284,15 +355,33 @@ fun NavRow(
done: Boolean = false, done: Boolean = false,
locked: Boolean = false, locked: Boolean = false,
leading: (@Composable () -> Unit)? = null, leading: (@Composable () -> Unit)? = null,
progress: Pair<Int, Int>? = null,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
) { ) {
val enabled = !locked && onClick != null val enabled = !locked && onClick != null
val textColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant val textColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
val rowModifier = if (enabled) Modifier.clickable(onClick = onClick!!) else Modifier val rowModifier = if (enabled) Modifier.clickable(onClick = onClick!!) else Modifier
val shownSubtitle = subtitle?.takeIf { it.isNotBlank() }
val shownProgress = progress?.takeIf { it.second > 0 }
ListItem( ListItem(
headlineContent = { Text(title, color = textColor) }, headlineContent = { Text(title, color = textColor) },
supportingContent = subtitle?.takeIf { it.isNotBlank() }?.let { supportingContent = if (shownSubtitle == null && shownProgress == null) null else {
{ Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) } {
Column {
shownSubtitle?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
shownProgress?.let { (doneCount, total) ->
Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
SmoothProgress(doneCount / total.toFloat(), Modifier.weight(1f).height(6.dp))
Spacer(Modifier.width(10.dp))
Text(
"$doneCount / $total",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}, },
leadingContent = { leadingContent = {
when { when {
@ -328,19 +417,38 @@ fun SmoothProgress(
progress: Float, progress: Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary, color: Color = MaterialTheme.colorScheme.primary,
trackColor: Color = MaterialTheme.colorScheme.surfaceVariant, trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
) { ) {
val animated by animateFloatAsState( val animated by animateFloatAsState(
targetValue = progress.coerceIn(0f, 1f), targetValue = progress.coerceIn(0f, 1f),
animationSpec = tween(durationMillis = 380), animationSpec = tween(durationMillis = 380),
label = "progress", label = "progress",
) )
LinearProgressIndicator( Box(modifier.heightIn(min = 4.dp).clip(CircleShape).background(trackColor)) {
progress = { animated }, if (animated > 0f) {
modifier = modifier, Box(Modifier.fillMaxHeight().fillMaxWidth(animated).clip(CircleShape).background(color))
color = color, }
trackColor = trackColor, }
) }
/** Circular progress with a label in the middle. */
@Composable
fun ProgressRing(progress: Float, modifier: Modifier = Modifier, stroke: Dp = 10.dp, label: @Composable () -> Unit) {
val animated by animateFloatAsState(progress.coerceIn(0f, 1f), tween(durationMillis = 380), label = "ring")
val track = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
val fill = MaterialTheme.colorScheme.primary
Box(modifier, contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val width = stroke.toPx()
val inset = width / 2
val arcSize = Size(size.width - width, size.height - width)
drawArc(track, 0f, 360f, false, Offset(inset, inset), arcSize, style = Stroke(width))
if (animated > 0f) {
drawArc(fill, -90f, 360f * animated, false, Offset(inset, inset), arcSize, style = Stroke(width, cap = StrokeCap.Round))
}
}
label()
}
} }
@Composable @Composable

View file

@ -26,7 +26,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.Architecture import androidx.compose.material.icons.filled.Architecture
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.AutoStories
import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Eco
import androidx.compose.material.icons.filled.Functions import androidx.compose.material.icons.filled.Functions
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@ -101,50 +104,61 @@ fun HomeScreen(vm: AppViewModel) {
item { item {
Card( Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) { ) {
Column(Modifier.padding(20.dp)) { Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
Text( Column(Modifier.weight(1f)) {
vm.tr("welcome_title"), Text(vm.tr("welcome_title"), style = MaterialTheme.typography.headlineMedium)
style = MaterialTheme.typography.headlineMedium, Spacer(Modifier.height(8.dp))
color = MaterialTheme.colorScheme.onPrimaryContainer, Text(
) vm.tr(vm.platform.welcomeKey),
Spacer(Modifier.height(8.dp)) style = MaterialTheme.typography.bodyMedium,
Text( color = MaterialTheme.colorScheme.onSurfaceVariant,
vm.tr(vm.platform.welcomeKey), )
style = MaterialTheme.typography.bodyMedium, }
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), Spacer(Modifier.width(16.dp))
) ProgressRing(pct, Modifier.size(104.dp)) {
Spacer(Modifier.height(16.dp)) Column(horizontalAlignment = Alignment.CenterHorizontally) {
SmoothProgress( Text(vm.fmt("stats_pct_fmt", (pct * 100).toInt()), style = MaterialTheme.typography.titleLarge)
progress = pct, Text(
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx),
color = MaterialTheme.colorScheme.onPrimaryContainer, style = MaterialTheme.typography.labelSmall,
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.18f), color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(6.dp)) }
Text( }
vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
)
} }
} }
} }
if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") { if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") {
item { UpdateBanner(vm) } item { UpdateBanner(vm) }
} }
val open = vm.content.subjects.filter { it.bool("open") }
val soon = vm.content.subjects.filter { !it.bool("open") }
item { SectionHeader(vm.tr("subjects_title")) } item { SectionHeader(vm.tr("subjects_title")) }
items(vm.content.subjects) { s -> items(open) { s ->
val open = s.bool("open") val part = subjectSum(vm, s)
NavRow( NavRow(
title = vm.tr(s.str("key")), title = vm.tr(s.str("key")),
subtitle = if (open) null else vm.tr("stats_locked"), leading = { SubjectIcon(s.str("icon"), true) },
locked = !open, progress = part.doneEx to part.totalEx,
leading = { SubjectIcon(s.str("icon"), open) }, onClick = { vm.goPage(s.str("target")) },
onClick = if (open) ({ vm.goPage(s.str("target")) }) else null,
) )
} }
if (soon.isNotEmpty()) {
item { SectionHeader(vm.tr("subjects_soon")) }
items(soon) { s ->
NavRow(
title = vm.tr(s.str("key")),
locked = true,
leading = { SubjectIcon(s.str("icon"), false) },
onClick = null,
)
}
}
} }
} }
@ -154,14 +168,18 @@ private fun ChangelogCard(vm: AppViewModel) {
val blocks = vm.content.changelog.take(3) val blocks = vm.content.changelog.take(3)
Card( Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) { ) {
Column(Modifier.padding(20.dp)) { Column(Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AutoAwesome, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text( Text(
vm.tr("changelog_title"), vm.tr("changelog_title"),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
val date = blocks.firstOrNull()?.str("date").orEmpty() val date = blocks.firstOrNull()?.str("date").orEmpty()
@ -169,7 +187,7 @@ private fun ChangelogCard(vm: AppViewModel) {
Text( Text(
date, date,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@ -180,7 +198,6 @@ private fun ChangelogCard(vm: AppViewModel) {
Text( Text(
"• $line", "• $line",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.padding(bottom = 4.dp), modifier = Modifier.padding(bottom = 4.dp),
) )
} }
@ -293,6 +310,67 @@ private fun Metric(label: String, value: String, modifier: Modifier) {
} }
} }
/** Done and total exercises behind one row of a years screen, or null when there is no count. */
fun routeProgress(vm: AppViewModel, r: Route): Pair<Int, Int>? {
val c = vm.content
val p = vm.progress
fun tally(lessons: List<J>, done: (Int) -> Boolean) = lessons.count { done(it.int("id")) } to lessons.size
return when (r) {
Route.NetMap -> tally(c.netLessons, p::netDone)
Route.Net2Map -> tally(c.itLessons("net2")) { p.courseDone("net2", it) }
Route.Net3Map -> tally(c.itLessons("net3")) { p.courseDone("net3", it) }
Route.Net4Map -> tally(c.itLessons("net4")) { p.courseDone("net4", it) }
Route.HwMap -> tally(c.hw, p::hwDone)
Route.Hw2Map -> tally(c.itLessons("hw2")) { p.courseDone("hw2", it) }
Route.Hw3Map -> tally(c.itLessons("hw3")) { p.courseDone("hw3", it) }
Route.Hw4Map -> tally(c.itLessons("hw4")) { p.courseDone("hw4", it) }
Route.OnMap -> tally(c.on, p::onDone)
Route.On2Map -> tally(c.on2, p::on2Done)
Route.On3Map -> tally(c.on3, p::on3Done)
Route.On4Map -> tally(c.on4, p::on4Done)
is Route.EnMap -> tally(c.enYear(r.year)) { p.enDone(r.year, it) }
is Route.DeMap -> tally(c.deYear(r.year)) { p.deDone(r.year, it) }
Route.Roadmap -> {
var done = 0
var total = 0
c.german.filter { it.bool("unlocked") }.forEach { u ->
val n = u.strs("names").size
total += n
done += (1..n).count { p.germanDone(u.int("id"), it) }
}
done to total
}
Route.LitYears -> listOf(
tally(c.lit, p::litDone), tally(c.lit2, p::lit2Done), tally(c.lit3, p::lit3Done), tally(c.lit4, p::lit4Done),
).let { parts -> parts.sumOf { it.first } to parts.sumOf { it.second } }
Route.LitMap -> tally(c.lit, p::litDone)
Route.Lit2Map -> tally(c.lit2, p::lit2Done)
Route.Lit3Map -> tally(c.lit3, p::lit3Done)
Route.Lit4Map -> tally(c.lit4, p::lit4Done)
Route.Mluvnice -> tally(c.mluvnice, p::mluvDone)
Route.ChemMap -> tally(c.chem, p::chemDone)
Route.BioMap -> tally(c.bio, p::bioDone)
Route.Mat0Map -> tally(c.mat0, p::mat0Done)
Route.MatMap -> tally(c.mat, p::matDone)
Route.Mat2Map -> tally(c.mat2, p::mat2Done)
Route.Mat3Map -> tally(c.mat3, p::mat3Done)
Route.Mat4Map -> tally(c.mat4, p::mat4Done)
Route.FyzMap -> tally(c.fyz, p::fyzDone)
Route.Fyz2Map -> tally(c.fyz2, p::fyz2Done)
Route.Fyz3Map -> tally(c.fyz3, p::fyz3Done)
Route.Fyz4Map -> tally(c.fyz4, p::fyz4Done)
Route.TgMap -> tally(c.tg, p::tgDone)
Route.Tg2Map -> tally(c.tg2, p::tg2Done)
Route.Tg3Map -> tally(c.tg3, p::tg3Done)
Route.Tg4Map -> tally(c.tg4, p::tg4Done)
Route.CsMap -> tally(c.cs, p::csDone)
Route.Cs2Map -> tally(c.cs2, p::cs2Done)
Route.Cs3Map -> tally(c.cs3, p::cs3Done)
Route.Cs4Map -> tally(c.cs4, p::cs4Done)
else -> null
}
}
private fun subjectSum(vm: AppViewModel, s: J): ProgressSum { private fun subjectSum(vm: AppViewModel, s: J): ProgressSum {
val sum = ProgressSum() val sum = ProgressSum()
when (s.str("target")) { when (s.str("target")) {
@ -801,10 +879,10 @@ fun NetYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("net_years_title"), vm.tr("net_years_sub")) { inner -> DetailScaffold(vm, vm.tr("net_years_title"), vm.tr("net_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("net_year1"), subtitle = vm.tr("net_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.NetMap) } NavRow(title = vm.tr("net_year1"), subtitle = vm.tr("net_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.NetMap)) { vm.go(Route.NetMap) }
NavRow(title = vm.tr("net_year2"), subtitle = vm.tr("net2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Net2Map) } NavRow(title = vm.tr("net_year2"), subtitle = vm.tr("net2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Net2Map)) { vm.go(Route.Net2Map) }
NavRow(title = vm.tr("net_year3"), subtitle = vm.tr("net3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Net3Map) } NavRow(title = vm.tr("net_year3"), subtitle = vm.tr("net3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Net3Map)) { vm.go(Route.Net3Map) }
NavRow(title = vm.tr("net_year4"), subtitle = vm.tr("net4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Net4Map) } NavRow(title = vm.tr("net_year4"), subtitle = vm.tr("net4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Net4Map)) { vm.go(Route.Net4Map) }
} }
} }
} }
@ -815,10 +893,10 @@ fun HwYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Technické vybavení"), vm.tr("hw_years_sub")) { inner -> DetailScaffold(vm, vm.tr("Technické vybavení"), vm.tr("hw_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("hw_year1"), subtitle = vm.tr("hw_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.HwMap) } NavRow(title = vm.tr("hw_year1"), subtitle = vm.tr("hw_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.HwMap)) { vm.go(Route.HwMap) }
NavRow(title = vm.tr("hw_year2"), subtitle = vm.tr("hw2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Hw2Map) } NavRow(title = vm.tr("hw_year2"), subtitle = vm.tr("hw2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Hw2Map)) { vm.go(Route.Hw2Map) }
NavRow(title = vm.tr("hw_year3"), subtitle = vm.tr("hw3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Hw3Map) } NavRow(title = vm.tr("hw_year3"), subtitle = vm.tr("hw3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Hw3Map)) { vm.go(Route.Hw3Map) }
NavRow(title = vm.tr("hw_year4"), subtitle = vm.tr("hw4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Hw4Map) } NavRow(title = vm.tr("hw_year4"), subtitle = vm.tr("hw4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Hw4Map)) { vm.go(Route.Hw4Map) }
} }
} }
} }
@ -829,7 +907,7 @@ fun DeHomeScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Deutsch"), vm.tr("de_home_sub")) { inner -> DetailScaffold(vm, vm.tr("Deutsch"), vm.tr("de_home_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("de_book_title"), subtitle = vm.tr("de_book_sub"), leading = { Text("U", style = MaterialTheme.typography.titleMedium) }) { NavRow(title = vm.tr("de_book_title"), subtitle = vm.tr("de_book_sub"), leading = { LeadingBadge("U") }, progress = routeProgress(vm, Route.Roadmap)) {
vm.go(Route.Roadmap) vm.go(Route.Roadmap)
} }
} }
@ -838,7 +916,8 @@ fun DeHomeScreen(vm: AppViewModel) {
NavRow( NavRow(
title = vm.tr("de_year$year"), title = vm.tr("de_year$year"),
subtitle = vm.tr("de_y${year}_sub"), subtitle = vm.tr("de_y${year}_sub"),
leading = { Text("$year.", style = MaterialTheme.typography.titleMedium) }, leading = { LeadingBadge("$year") },
progress = routeProgress(vm, Route.DeMap(year)),
) { vm.go(Route.DeMap(year)) } ) { vm.go(Route.DeMap(year)) }
} }
} }
@ -854,7 +933,8 @@ fun EnYearsScreen(vm: AppViewModel) {
NavRow( NavRow(
title = vm.tr("en_year$year"), title = vm.tr("en_year$year"),
subtitle = vm.tr("en_y${year}_sub"), subtitle = vm.tr("en_y${year}_sub"),
leading = { Text("$year.", style = MaterialTheme.typography.titleMedium) }, leading = { LeadingBadge("$year") },
progress = routeProgress(vm, Route.EnMap(year)),
) { vm.go(Route.EnMap(year)) } ) { vm.go(Route.EnMap(year)) }
} }
} }
@ -866,10 +946,10 @@ fun OnYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Občanská nauka"), vm.tr("on_years_sub")) { inner -> DetailScaffold(vm, vm.tr("Občanská nauka"), vm.tr("on_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("on_year1"), subtitle = vm.tr("on_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.OnMap) } NavRow(title = vm.tr("on_year1"), subtitle = vm.tr("on_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.OnMap)) { vm.go(Route.OnMap) }
NavRow(title = vm.tr("on_year2"), subtitle = vm.tr("on2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.On2Map) } NavRow(title = vm.tr("on_year2"), subtitle = vm.tr("on2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.On2Map)) { vm.go(Route.On2Map) }
NavRow(title = vm.tr("on_year3"), subtitle = vm.tr("on3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.On3Map) } NavRow(title = vm.tr("on_year3"), subtitle = vm.tr("on3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.On3Map)) { vm.go(Route.On3Map) }
NavRow(title = vm.tr("on_year4"), subtitle = vm.tr("on4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.On4Map) } NavRow(title = vm.tr("on_year4"), subtitle = vm.tr("on4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.On4Map)) { vm.go(Route.On4Map) }
} }
} }
} }
@ -880,10 +960,10 @@ fun LitYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Literatura"), vm.tr("lit_years_sub")) { inner -> DetailScaffold(vm, vm.tr("Literatura"), vm.tr("lit_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("lit_year1"), subtitle = vm.tr("lit_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.LitMap) } NavRow(title = vm.tr("lit_year1"), subtitle = vm.tr("lit_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.LitMap)) { vm.go(Route.LitMap) }
NavRow(title = vm.tr("lit_year2"), subtitle = vm.tr("lit2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Lit2Map) } NavRow(title = vm.tr("lit_year2"), subtitle = vm.tr("lit2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Lit2Map)) { vm.go(Route.Lit2Map) }
NavRow(title = vm.tr("lit_year3"), subtitle = vm.tr("lit3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Lit3Map) } NavRow(title = vm.tr("lit_year3"), subtitle = vm.tr("lit3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Lit3Map)) { vm.go(Route.Lit3Map) }
NavRow(title = vm.tr("lit_year4"), subtitle = vm.tr("lit4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Lit4Map) } NavRow(title = vm.tr("lit_year4"), subtitle = vm.tr("lit4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Lit4Map)) { vm.go(Route.Lit4Map) }
} }
} }
} }
@ -894,11 +974,11 @@ fun MatYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Matematika"), vm.tr("mat_years_sub")) { inner -> DetailScaffold(vm, vm.tr("Matematika"), vm.tr("mat_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("mat0_year"), subtitle = vm.tr("mat0_sub"), leading = { Text("ZŠ", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Mat0Map) } NavRow(title = vm.tr("mat0_year"), subtitle = vm.tr("mat0_sub"), leading = { LeadingBadge("ZŠ") }, progress = routeProgress(vm, Route.Mat0Map)) { vm.go(Route.Mat0Map) }
NavRow(title = vm.tr("mat_year1"), subtitle = vm.tr("mat_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.MatMap) } NavRow(title = vm.tr("mat_year1"), subtitle = vm.tr("mat_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.MatMap)) { vm.go(Route.MatMap) }
NavRow(title = vm.tr("mat_year2"), subtitle = vm.tr("mat2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Mat2Map) } NavRow(title = vm.tr("mat_year2"), subtitle = vm.tr("mat2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Mat2Map)) { vm.go(Route.Mat2Map) }
NavRow(title = vm.tr("mat_year3"), subtitle = vm.tr("mat3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Mat3Map) } NavRow(title = vm.tr("mat_year3"), subtitle = vm.tr("mat3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Mat3Map)) { vm.go(Route.Mat3Map) }
NavRow(title = vm.tr("mat_year4"), subtitle = vm.tr("mat4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Mat4Map) } NavRow(title = vm.tr("mat_year4"), subtitle = vm.tr("mat4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Mat4Map)) { vm.go(Route.Mat4Map) }
} }
} }
} }
@ -909,10 +989,10 @@ fun CsYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Programování"), vm.tr("cs_years_sub")) { inner -> DetailScaffold(vm, vm.tr("Programování"), vm.tr("cs_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("cs_year1"), subtitle = vm.tr("cs_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.CsMap) } NavRow(title = vm.tr("cs_year1"), subtitle = vm.tr("cs_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.CsMap)) { vm.go(Route.CsMap) }
NavRow(title = vm.tr("cs_year2"), subtitle = vm.tr("cs2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Cs2Map) } NavRow(title = vm.tr("cs_year2"), subtitle = vm.tr("cs2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Cs2Map)) { vm.go(Route.Cs2Map) }
NavRow(title = vm.tr("cs_year3"), subtitle = vm.tr("cs3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Cs3Map) } NavRow(title = vm.tr("cs_year3"), subtitle = vm.tr("cs3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Cs3Map)) { vm.go(Route.Cs3Map) }
NavRow(title = vm.tr("cs_year4"), subtitle = vm.tr("cs4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Cs4Map) } NavRow(title = vm.tr("cs_year4"), subtitle = vm.tr("cs4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Cs4Map)) { vm.go(Route.Cs4Map) }
} }
} }
} }
@ -923,10 +1003,10 @@ fun TgYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Technická grafika"), vm.tr("tg_years_sub")) { inner -> DetailScaffold(vm, vm.tr("Technická grafika"), vm.tr("tg_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("tg_year1"), subtitle = vm.tr("tg_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.TgMap) } NavRow(title = vm.tr("tg_year1"), subtitle = vm.tr("tg_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.TgMap)) { vm.go(Route.TgMap) }
NavRow(title = vm.tr("tg_year2"), subtitle = vm.tr("tg2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Tg2Map) } NavRow(title = vm.tr("tg_year2"), subtitle = vm.tr("tg2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Tg2Map)) { vm.go(Route.Tg2Map) }
NavRow(title = vm.tr("tg_year3"), subtitle = vm.tr("tg3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Tg3Map) } NavRow(title = vm.tr("tg_year3"), subtitle = vm.tr("tg3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Tg3Map)) { vm.go(Route.Tg3Map) }
NavRow(title = vm.tr("tg_year4"), subtitle = vm.tr("tg4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Tg4Map) } NavRow(title = vm.tr("tg_year4"), subtitle = vm.tr("tg4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Tg4Map)) { vm.go(Route.Tg4Map) }
} }
} }
} }
@ -937,10 +1017,10 @@ fun FyzYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Fyzika"), vm.tr("fyz_years_sub")) { inner -> DetailScaffold(vm, vm.tr("Fyzika"), vm.tr("fyz_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("fyz_year1"), subtitle = vm.tr("fyz_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.FyzMap) } NavRow(title = vm.tr("fyz_year1"), subtitle = vm.tr("fyz_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.FyzMap)) { vm.go(Route.FyzMap) }
NavRow(title = vm.tr("fyz_year2"), subtitle = vm.tr("fyz2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Fyz2Map) } NavRow(title = vm.tr("fyz_year2"), subtitle = vm.tr("fyz2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Fyz2Map)) { vm.go(Route.Fyz2Map) }
NavRow(title = vm.tr("fyz_year3"), subtitle = vm.tr("fyz3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Fyz3Map) } NavRow(title = vm.tr("fyz_year3"), subtitle = vm.tr("fyz3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Fyz3Map)) { vm.go(Route.Fyz3Map) }
NavRow(title = vm.tr("fyz_year4"), subtitle = vm.tr("fyz4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Fyz4Map) } NavRow(title = vm.tr("fyz_year4"), subtitle = vm.tr("fyz4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Fyz4Map)) { vm.go(Route.Fyz4Map) }
} }
} }
} }
@ -954,11 +1034,17 @@ fun SciMapScreen(vm: AppViewModel) {
NavRow( NavRow(
title = vm.tr("Chemie"), title = vm.tr("Chemie"),
subtitle = vm.tr("chem_sub"), subtitle = vm.tr("chem_sub"),
leading = { Icon(Icons.Filled.Science, null, tint = MaterialTheme.colorScheme.primary) }, leading = { LeadingIcon(Icons.Filled.Science) },
progress = routeProgress(vm, Route.ChemMap),
) { vm.go(Route.ChemMap) } ) { vm.go(Route.ChemMap) }
} }
item { item {
NavRow(title = vm.tr("Biologie"), subtitle = vm.tr("bio_sub")) { vm.go(Route.BioMap) } NavRow(
title = vm.tr("Biologie"),
subtitle = vm.tr("bio_sub"),
leading = { LeadingIcon(Icons.Filled.Eco) },
progress = routeProgress(vm, Route.BioMap),
) { vm.go(Route.BioMap) }
} }
} }
} }
@ -969,20 +1055,26 @@ fun CzechMapScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Český jazyk a literatura"), vm.tr("czech_sub")) { inner -> DetailScaffold(vm, vm.tr("Český jazyk a literatura"), vm.tr("czech_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
item { item {
NavRow(title = vm.tr("Literatura"), subtitle = vm.tr("lit_years_sub")) { vm.go(Route.LitYears) } NavRow(
title = vm.tr("Literatura"),
subtitle = vm.tr("lit_years_sub"),
leading = { LeadingIcon(Icons.Filled.AutoStories) },
progress = routeProgress(vm, Route.LitYears),
) { vm.go(Route.LitYears) }
} }
item { item {
NavRow( NavRow(
title = vm.tr("Mluvnice"), title = vm.tr("Mluvnice"),
subtitle = vm.tr("mluv_sub"), subtitle = vm.tr("mluv_sub"),
leading = { Icon(Icons.Filled.FormatListNumbered, null, tint = MaterialTheme.colorScheme.primary) }, leading = { LeadingIcon(Icons.Filled.FormatListNumbered) },
progress = routeProgress(vm, Route.Mluvnice),
) { vm.go(Route.Mluvnice) } ) { vm.go(Route.Mluvnice) }
} }
item { item {
NavRow( NavRow(
title = vm.tr("Maturitní četba"), title = vm.tr("Maturitní četba"),
subtitle = vm.tr("reading_sub"), subtitle = vm.tr("reading_sub"),
leading = { Icon(Icons.AutoMirrored.Filled.MenuBook, null, tint = MaterialTheme.colorScheme.primary) }, leading = { LeadingIcon(Icons.AutoMirrored.Filled.MenuBook) },
) { vm.go(Route.ReadingList) } ) { vm.go(Route.ReadingList) }
} }
} }
@ -1045,6 +1137,52 @@ fun BookScreen(vm: AppViewModel, id: String) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Lesson slides // Lesson slides
/** One explanation slide: label, title, tip and numbered points. */
@Composable
fun SlideContent(slide: J) {
Column {
Text(slide.str("kicker"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(slide.str("title"), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 4.dp, bottom = 12.dp))
slide.strOrNull("tip")?.let { tip ->
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Lightbulb, null, tint = MaterialTheme.colorScheme.onSecondaryContainer)
Spacer(Modifier.width(10.dp))
Text(tip, style = MaterialTheme.typography.bodyMedium)
}
}
}
slide.strs("lines").forEachIndexed { index, line ->
val numbered = Regex("""^(\d+)\.\s+(.*)$""").find(line)
val badge = numbered?.groupValues?.get(1) ?: "${index + 1}"
val text = numbered?.groupValues?.get(2) ?: line
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 5.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), androidx.compose.foundation.shape.RoundedCornerShape(16.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.Top,
) {
Box(
Modifier
.padding(top = 2.dp)
.size(28.dp)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), CircleShape),
contentAlignment = Alignment.Center,
) {
Text(badge, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
}
}
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SlidesScreen( fun SlidesScreen(
@ -1095,52 +1233,13 @@ fun SlidesScreen(
}, },
) { inner -> ) { inner ->
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize().padding(inner)) { page -> HorizontalPager(state = pager, modifier = Modifier.fillMaxSize().padding(inner)) { page ->
val slide = slides[page]
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
) { ) {
Text(slide.str("kicker"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) SlideContent(slides[page])
Text(slide.str("title"), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 4.dp, bottom = 12.dp))
slide.strOrNull("tip")?.let { tip ->
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Lightbulb, null, tint = MaterialTheme.colorScheme.onSecondaryContainer)
Spacer(Modifier.width(10.dp))
Text(tip, style = MaterialTheme.typography.bodyMedium)
}
}
}
slide.strs("lines").forEachIndexed { index, line ->
val numbered = Regex("""^(\d+)\.\s+(.*)$""").find(line)
val badge = numbered?.groupValues?.get(1) ?: "${index + 1}"
val text = numbered?.groupValues?.get(2) ?: line
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 5.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), androidx.compose.foundation.shape.RoundedCornerShape(16.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.Top,
) {
Box(
Modifier
.padding(top = 2.dp)
.size(28.dp)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), CircleShape),
contentAlignment = Alignment.Center,
) {
Text(badge, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
}
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
} }

View file

@ -117,6 +117,7 @@
| search_page | Stránka | Page | | search_page | Stránka | Page |
| search_home | Úvod | Welcome | | search_home | Úvod | Welcome |
| subjects_title | Předměty | Subjects | | subjects_title | Předměty | Subjects |
| subjects_soon | Připravujeme | Coming soon |
| subjects_sub | Otevřený předmět spustí učební cestu. Zamčené zatím jen čekají. | An open subject opens its path. Locked ones are placeholders for now. | | subjects_sub | Otevřený předmět spustí učební cestu. Zamčené zatím jen čekají. | An open subject opens its path. Locked ones are placeholders for now. |
# Čeština # Čeština
@ -262,6 +263,8 @@
| cs_run | Spustit | Run | | cs_run | Spustit | Run |
| cs_reset | Obnovit ukázku | Reset example | | cs_reset | Obnovit ukázku | Reset example |
| cs_running | Překládám… | Compiling… | | cs_running | Překládám… | Compiling… |
| cs_done | Hotovo | Done |
| cs_failed | Chyba | Error |
| cs_empty | Zatím nic. Upravte kód a spusťte ho. | Nothing yet. Edit the code and run it. | | cs_empty | Zatím nic. Upravte kód a spusťte ho. | Nothing yet. Edit the code and run it. |
| cs_timeout | Program běžel příliš dlouho a byl zastaven. | The program ran too long and was stopped. | | cs_timeout | Program běžel příliš dlouho a byl zastaven. | The program ran too long and was stopped. |
| cs_unavailable | Tady C# nespustíte. Na počítači s nainstalovaným .NET se výstup objeví vedle kódu. | C# cannot run here. On a computer with .NET installed the output appears beside the code. | | cs_unavailable | Tady C# nespustíte. Na počítači s nainstalovaným .NET se výstup objeví vedle kódu. | C# cannot run here. On a computer with .NET installed the output appears beside the code. |

View file

@ -50,109 +50,47 @@ enum MacChrome {
static let pageInset: CGFloat = 28 static let pageInset: CGFloat = 28
static let cardPadding: CGFloat = 22 static let cardPadding: CGFloat = 22
static let listRowMinHeight: CGFloat = 52 static let listRowMinHeight: CGFloat = 52
static let homeTileMin: CGFloat = 240 static let homeTileMin: CGFloat = 260
static let homeTileHeight: CGFloat = 168 static let homeTileHeight: CGFloat = 172
static let pathNode: CGFloat = 84 static let pathNode: CGFloat = 84
/// Pages stop growing here so cards and text keep their shape on wide displays.
static let contentWidth: CGFloat = 1240
static let readableWidth: CGFloat = 860
static let actionWidth: CGFloat = 520
} }
enum MacGrid { /// Fixed-height cards in as many columns as fit, centred and capped in width.
/// Picks a column count that fills the width and keeps tiles wider than they are tall.
static func columns(width: CGFloat, height: CGFloat, count: Int, minWidth: CGFloat, minHeight: CGFloat, spacing: CGFloat) -> Int {
guard count > 0, width > 0 else { return 1 }
let maxCols = max(1, min(count, Int((width + spacing) / (minWidth + spacing))))
var best = maxCols
var bestScore = CGFloat.greatestFiniteMagnitude
let offeredH = height > 1 ? height : minHeight * CGFloat(count)
for cols in 1...maxCols {
let rows = Int(ceil(Double(count) / Double(cols)))
let tileW = (width - spacing * CGFloat(cols - 1)) / CGFloat(cols)
if tileW + 0.5 < minWidth { continue }
let needed = CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0))
let tileH = max(minHeight, (offeredH - spacing * CGFloat(max(rows - 1, 0))) / CGFloat(rows))
let aspect = tileH / max(tileW, 1)
let rem = count % cols
let ragged = rem == 0 ? 0 : CGFloat(1.5)
let lonely = rem == 1 ? CGFloat(1.4) : 0
let tooTall = max(0, aspect - 1.05) * 3
let overflow = max(0, needed - offeredH)
let score = abs(aspect - 0.78) * 2 + ragged + lonely + tooTall + overflow / 180
if score < bestScore {
bestScore = score
best = cols
}
}
return best
}
static func contentHeight(width: CGFloat, height: CGFloat, count: Int, minWidth: CGFloat, minHeight: CGFloat, spacing: CGFloat) -> CGFloat {
let cols = columns(width: width, height: height, count: count, minWidth: minWidth, minHeight: minHeight, spacing: spacing)
let rows = max(1, Int(ceil(Double(max(count, 1)) / Double(cols))))
return CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0))
}
}
struct MacHeaderHeight: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
/// Card grid that grows into the space it is given and scrolls only when the
/// minimum tile size no longer fits.
struct MacFillGrid<Content: View>: View { struct MacFillGrid<Content: View>: View {
var count: Int var count: Int
var minWidth: CGFloat = 240 var minWidth: CGFloat = 240
var minHeight: CGFloat = 148 var minHeight: CGFloat = 148
var spacing: CGFloat = 16 var spacing: CGFloat = 16
var inset: CGFloat = 28 var inset: CGFloat = 28
/// Home embeds the grid in its own scroll view. A second scroller here var maxTileWidth: CGFloat = 340
/// swallows the wheel once a banner (the changelog) makes the page taller.
var scrolls = true
@ViewBuilder var content: (Int) -> Content @ViewBuilder var content: (Int) -> Content
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
let innerW = max(geo.size.width - inset * 2, minWidth) let available = max(geo.size.width - inset * 2, minWidth)
let available = max(geo.size.height - inset * 2, minHeight) let fit = max(1, Int((available + spacing) / (minWidth + spacing)))
let cols = MacGrid.columns(width: innerW, height: available, count: count, minWidth: minWidth, minHeight: minHeight, spacing: spacing) let cols = max(1, min(count, fit))
let rows = max(1, Int(ceil(Double(max(count, 1)) / Double(cols)))) let width = min(available, CGFloat(cols) * maxTileWidth + CGFloat(cols - 1) * spacing)
let floorH = CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0)) ScrollView {
let gridH = max(available, floorH) LazyVGrid(
let tileH = max(minHeight, (gridH - spacing * CGFloat(max(rows - 1, 0))) / CGFloat(rows)) columns: Array(repeating: GridItem(.flexible(), spacing: spacing, alignment: .top), count: cols),
let tileW = (innerW - spacing * CGFloat(max(cols - 1, 0))) / CGFloat(max(cols, 1)) alignment: .leading,
let lastCount = count - (rows - 1) * cols spacing: spacing
let tiles = VStack(spacing: spacing) { ) {
ForEach(0..<rows, id: \.self) { row in ForEach(0..<count, id: \.self) { i in
let stretch = row < rows - 1 || lastCount > 1 content(i).frame(height: minHeight)
HStack(spacing: spacing) {
ForEach(0..<cols, id: \.self) { col in
let index = row * cols + col
if index < count {
content(index)
.frame(
maxWidth: stretch ? .infinity : tileW,
minHeight: tileH,
maxHeight: tileH,
alignment: .topLeading
)
}
}
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
} .frame(width: width)
.frame(height: gridH, alignment: .top) .padding(inset)
.padding(inset) .frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, minHeight: scrolls ? geo.size.height : nil, alignment: .top)
if scrolls {
ScrollView { tiles }
.scrollDisabled(floorH + inset * 2 <= geo.size.height + 1)
} else {
tiles
} }
} }
.softAppear()
} }
} }
@ -162,45 +100,103 @@ struct MacLinkCard: View {
var badge: String? = nil var badge: String? = nil
var systemImage: String? = nil var systemImage: String? = nil
var locked = false var locked = false
var progress: (done: Int, total: Int)? = nil
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { let tint = locked ? Color.secondary : Color.accentColor
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center) { HStack(alignment: .center) {
if let badge { Group {
Text(badge) if let systemImage {
.font(.title.weight(.bold)) Image(systemName: systemImage)
.foregroundStyle(locked ? Color.secondary : Color.accentColor) .font(.title3.weight(.semibold))
} } else if let badge {
if let systemImage { Text(badge.hasSuffix(".") ? String(badge.dropLast()) : badge)
Image(systemName: systemImage) .font(.title3.weight(.bold))
.font(.title2.weight(.semibold)) .monospacedDigit()
.foregroundStyle(locked ? Color.secondary : Color.accentColor) }
} }
.foregroundStyle(tint)
.frame(width: 40, height: 40)
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
Spacer(minLength: 0) Spacer(minLength: 0)
if locked { Image(systemName: locked ? "lock.fill" : "chevron.right")
Image(systemName: "lock.fill") .font(.body.weight(.semibold))
.foregroundStyle(.tertiary)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title3.weight(.semibold))
.foregroundStyle(locked ? .secondary : .primary)
.lineLimit(2)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
if let progress, progress.total > 0 {
HStack(spacing: 10) {
ProgressBar(value: Double(progress.done) / Double(progress.total))
Text("\(progress.done) / \(progress.total)")
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
Text(title)
.font(.title2.weight(.semibold))
.foregroundStyle(locked ? .secondary : .primary)
.fixedSize(horizontal: false, vertical: true)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
} }
.padding(22) .padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.softSurface(cornerRadius: 22) .softSurface(cornerRadius: 20)
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
} }
} }
#endif #endif
/// Flat capsule bar. Unlike ProgressView it shows nothing at zero.
struct ProgressBar: View {
let value: Double
var height: CGFloat = 8
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.primary.opacity(0.08))
if value > 0 {
Capsule()
.fill(Color.accentColor)
.frame(width: max(height, geo.size.width * min(value, 1)))
}
}
}
.frame(height: height)
.animation(Motion.settle, value: value)
}
}
struct ProgressRing<Label: View>: View {
let value: Double
var lineWidth: CGFloat = 12
@ViewBuilder var label: () -> Label
var body: some View {
ZStack {
Circle().stroke(Color.primary.opacity(0.08), lineWidth: lineWidth)
if value > 0 {
Circle()
.trim(from: 0, to: min(value, 1))
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
}
label()
}
.animation(Motion.settle, value: value)
}
}
struct FlowLayout: Layout { struct FlowLayout: Layout {
var spacing: CGFloat = 6 var spacing: CGFloat = 6
@ -574,15 +570,28 @@ extension View {
#endif #endif
} }
/// Full-width, leading-aligned content for exercise screens. /// Leading-aligned content for exercise screens; on the Mac a centred readable column.
func exerciseContent() -> some View { func exerciseContent() -> some View {
frame(maxWidth: .infinity, alignment: .leading) #if os(macOS)
#if os(macOS) frame(maxWidth: MacChrome.readableWidth, alignment: .leading)
.padding(.horizontal, MacChrome.pageInset) .padding(.horizontal, MacChrome.pageInset)
.padding(.vertical, 20) .padding(.vertical, 28)
#else .frame(maxWidth: .infinity)
#else
frame(maxWidth: .infinity, alignment: .leading)
.padding() .padding()
#endif #endif
}
/// Caps a Mac page at [width] and centres it; no-op on iOS.
@ViewBuilder
func macPage(_ width: CGFloat? = nil) -> some View {
#if os(macOS)
frame(maxWidth: width ?? MacChrome.contentWidth)
.frame(maxWidth: .infinity)
#else
self
#endif
} }
/// macOS content lists use the inset style so they sit under the floating /// macOS content lists use the inset style so they sit under the floating
@ -636,7 +645,8 @@ struct BottomAction<Extra: View>: View {
}()) }())
#if os(macOS) #if os(macOS)
.padding(.top, 12) .padding(.top, 12)
.padding(.bottom, 10) .padding(.bottom, 14)
.frame(maxWidth: MacChrome.actionWidth)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
#else #else
.padding(.top, 8) .padding(.top, 8)
@ -645,10 +655,11 @@ struct BottomAction<Extra: View>: View {
} }
} }
/// Primary + secondary text sized for desktop lists. /// Primary + secondary text sized for desktop lists, with optional exercise progress.
struct ListRowLabel: View { struct ListRowLabel: View {
let title: String let title: String
var subtitle: String? = nil var subtitle: String? = nil
var progress: (done: Int, total: Int)? = nil
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
@ -665,6 +676,25 @@ struct ListRowLabel: View {
#endif #endif
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let progress, progress.total > 0 {
ProgressLine(done: progress.done, total: progress.total)
.padding(.top, 4)
}
}
}
}
/// Slim bar with "done / total" beside it, for list rows.
struct ProgressLine: View {
let done: Int
let total: Int
var body: some View {
HStack(spacing: 8) {
ProgressBar(value: total == 0 ? 0 : Double(done) / Double(total), height: 5)
Text("\(done) / \(total)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
} }
} }
} }

View file

@ -12,21 +12,9 @@ struct ExerciseScaffold<Content: View>: View {
var body: some View { var body: some View {
let p = vm.palette let p = vm.palette
Group { ScrollView {
#if os(macOS) VStack(alignment: .leading, spacing: 8) { content() }
GeometryReader { geo in .exerciseContent()
ScrollView {
VStack(alignment: .leading, spacing: 8) { content() }
.exerciseContent()
.frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .center)
}
}
#else
ScrollView {
VStack(alignment: .leading, spacing: 8) { content() }
.exerciseContent()
}
#endif
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.detailScreen(title, subtitle: sub) .detailScreen(title, subtitle: sub)

View file

@ -8,6 +8,8 @@ struct MapNode: Identifiable {
let current: Bool let current: Bool
var finish: Bool = false var finish: Bool = false
var destination: Route? var destination: Route?
/// Glyph for a node that has no number, such as a side branch.
var symbol: String? = nil
} }
private struct NodePos { private struct NodePos {
@ -256,6 +258,8 @@ struct PathMap: View {
.overlay { .overlay {
if node.locked { if node.locked {
Circle().strokeBorder(palette.lockedBorder, lineWidth: 1) Circle().strokeBorder(palette.lockedBorder, lineWidth: 1)
} else if !node.current && !node.done {
Circle().strokeBorder(palette.accent.opacity(0.55), lineWidth: 2.5)
} }
} }
if node.finish { if node.finish {
@ -298,6 +302,13 @@ struct PathMap: View {
#else #else
.font(.headline.weight(.black)) .font(.headline.weight(.black))
#endif #endif
} else if let symbol = node.symbol {
Image(systemName: symbol)
#if os(macOS)
.font(.title3.weight(.semibold))
#else
.font(.body.weight(.semibold))
#endif
} }
Image(systemName: "checkmark") Image(systemName: "checkmark")
#if os(macOS) #if os(macOS)
@ -315,6 +326,14 @@ struct PathMap: View {
.font(.headline.weight(.black)) .font(.headline.weight(.black))
#endif #endif
.foregroundStyle(node.current ? palette.onAccent : palette.text) .foregroundStyle(node.current ? palette.onAccent : palette.text)
} else if let symbol = node.symbol {
Image(systemName: symbol)
#if os(macOS)
.font(.title2.weight(.semibold))
#else
.font(.headline.weight(.semibold))
#endif
.foregroundStyle(node.current ? palette.onAccent : palette.text)
} }
} }
.frame(width: nodeSize, height: nodeSize) .frame(width: nodeSize, height: nodeSize)

File diff suppressed because it is too large Load diff