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 platform = DesktopPlatform(info.getProperty("commit") ?: "dev", prefs, install)
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")
?.use { ImageIO.read(it) }?.let { BitmapPainter(it.toComposeImageBitmap()) }

View file

@ -1,32 +1,59 @@
package org.maturita.maturita.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
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
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
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.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.buildAnnotatedString
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.max
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp
import org.maturita.maturita.AppViewModel
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. */
@Composable
fun CodeLessonScreen(vm: AppViewModel, lesson: J, onSuccess: () -> Unit) {
val key = lesson.str("key")
val starter = lesson.str("code")
var code by remember(lesson.str("key")) { mutableStateOf(starter) }
var stdin by remember(lesson.str("key")) { mutableStateOf("") }
var output by remember(lesson.str("key")) { mutableStateOf("") }
var running by remember(lesson.str("key")) { mutableStateOf(false) }
var code by remember(key) { mutableStateOf(starter) }
var stdin by remember(key) { mutableStateOf("") }
var output by remember(key) { mutableStateOf("") }
var status by remember(key) { mutableStateOf("") }
val running = status == "running"
val needsInput = "ReadLine" in code
fun run() {
if (running) return
running = true
status = "running"
output = vm.tr("cs_running")
vm.platform.runCSharp(code, stdin) { result ->
running = false
vm.platform.runCSharp(code, if (needsInput) stdin else "") { result ->
status = result.status
output = when (result.status) {
"unavailable" -> vm.tr("cs_unavailable")
"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 ->
BoxWithConstraints(Modifier.fillMaxSize().padding(inner).padding(12.dp)) {
if (maxWidth >= 840.dp) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
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())
fun reset() {
code = starter
output = ""
status = ""
}
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 {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Explanation(vm, lesson, Modifier.fillMaxWidth())
EditorColumn(vm, code, { code = it }, stdin, { stdin = it }, expand = false, Modifier.fillMaxWidth())
OutputColumn(vm, output, running, { run() }, { code = starter }, expand = false, Modifier.fillMaxWidth())
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
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
private fun Explanation(vm: AppViewModel, lesson: J, modifier: Modifier) {
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) {
lesson.arr("slides").forEach { slide ->
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)
}
}
private fun Explanation(lesson: J) {
Column(verticalArrangement = Arrangement.spacedBy(28.dp)) {
lesson.arr("slides").forEach { SlideContent(it) }
}
}
@Composable
private fun EditorColumn(
private fun EditorCard(
vm: AppViewModel,
code: String,
onCode: (String) -> Unit,
stdin: String,
onStdin: (String) -> Unit,
expand: Boolean,
running: Boolean,
onRun: () -> Unit,
onReset: () -> Unit,
compact: Boolean,
modifier: Modifier,
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(vm.tr("cs_code"), style = MaterialTheme.typography.titleMedium)
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
val highlight = remember(dark) { CSharpHighlight(dark) }
Surface(modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Column {
Row(
Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(Icons.Filled.Code, null, tint = MaterialTheme.colorScheme.primary)
Text("Program.cs", style = MaterialTheme.typography.titleSmall.copy(fontFamily = FontFamily.Monospace))
Spacer(Modifier.weight(1f))
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),
modifier = Modifier
.then(if (expand) Modifier.weight(1f) else Modifier.height(240.dp))
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(12.dp)
.verticalScroll(rememberScrollState()),
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)
BasicTextField(
OutlinedTextField(
value = stdin,
onValueChange = onStdin,
textStyle = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainer).padding(12.dp),
textStyle = codeStyle(),
minLines = 1,
maxLines = 4,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun OutputColumn(
vm: AppViewModel,
output: String,
running: Boolean,
onRun: () -> Unit,
onReset: () -> Unit,
expand: Boolean,
modifier: Modifier,
private fun OutputCard(vm: AppViewModel, output: String, status: String, modifier: Modifier) {
val muted = output.isEmpty() || status == "running"
Surface(modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Column {
Row(
Modifier.fillMaxWidth().height(48.dp).padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(onClick = onRun, enabled = !running) { Text(vm.tr("cs_run")) }
OutlinedButton(onClick = onReset, enabled = !running) { Text(vm.tr("cs_reset")) }
Icon(Icons.Filled.Terminal, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(vm.tr("cs_output"), style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.weight(1f))
StatusBadge(vm, status)
}
Text(vm.tr("cs_output"), style = MaterialTheme.typography.titleMedium)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
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()),
color = if (muted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp),
)
}
}
}
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.shrinkVertically
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
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.height
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
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.LazyListScope
import androidx.compose.foundation.text.KeyboardOptions
@ -44,7 +61,6 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
@ -150,7 +166,19 @@ fun AppNavBar(vm: AppViewModel) {
private fun navIconColor(selected: Boolean): Color =
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)
@Composable
fun RootScaffold(
@ -159,24 +187,40 @@ fun RootScaffold(
showSearch: Boolean = true,
content: LazyListScope.() -> Unit,
) {
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
actions = {
BoxWithConstraints {
val wide = maxWidth >= 600.dp
val scroll = if (wide) {
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
} else {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
}
val actions: @Composable RowScope.() -> Unit = {
if (showSearch) {
IconButton(onClick = { vm.searchOpen = true }) {
Icon(Icons.Filled.Search, contentDescription = vm.tr("search_placeholder"))
}
}
},
}
Scaffold(
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
topBar = {
if (wide) {
TopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineSmall) },
actions = actions,
scrollBehavior = scroll,
)
} 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(),
@ -185,6 +229,8 @@ fun RootScaffold(
) { content() }
}
}
}
}
/** Pushed screen: back arrow, title with optional subtitle, optional bottom action bar. */
@OptIn(ExperimentalMaterial3Api::class)
@ -194,6 +240,7 @@ fun DetailScaffold(
title: String,
subtitle: String? = null,
bottomBar: (@Composable () -> Unit)? = null,
maxContentWidth: Dp = ContentMaxWidth,
content: @Composable (PaddingValues) -> Unit,
) {
val scroll = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@ -224,8 +271,9 @@ fun DetailScaffold(
)
},
bottomBar = { bottomBar?.invoke() },
content = content,
)
) { inner ->
CenteredColumn(maxContentWidth) { content(inner) }
}
}
/** Feedback line plus the primary action, anchored at the bottom of an exercise. */
@ -242,6 +290,8 @@ fun ActionBar(
Column(
Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.widthIn(max = ActionMaxWidth)
.windowInsetsPadding(WindowInsets.navigationBars)
.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. */
@Composable
fun NavRow(
@ -284,15 +355,33 @@ fun NavRow(
done: Boolean = false,
locked: Boolean = false,
leading: (@Composable () -> Unit)? = null,
progress: Pair<Int, Int>? = null,
onClick: (() -> Unit)?,
) {
val enabled = !locked && onClick != null
val textColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
val rowModifier = if (enabled) Modifier.clickable(onClick = onClick!!) else Modifier
val shownSubtitle = subtitle?.takeIf { it.isNotBlank() }
val shownProgress = progress?.takeIf { it.second > 0 }
ListItem(
headlineContent = { Text(title, color = textColor) },
supportingContent = subtitle?.takeIf { it.isNotBlank() }?.let {
{ Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
supportingContent = if (shownSubtitle == null && shownProgress == null) null else {
{
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 = {
when {
@ -328,19 +417,38 @@ fun SmoothProgress(
progress: Float,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
trackColor: Color = MaterialTheme.colorScheme.surfaceVariant,
trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
) {
val animated by animateFloatAsState(
targetValue = progress.coerceIn(0f, 1f),
animationSpec = tween(durationMillis = 380),
label = "progress",
)
LinearProgressIndicator(
progress = { animated },
modifier = modifier,
color = color,
trackColor = trackColor,
)
Box(modifier.heightIn(min = 4.dp).clip(CircleShape).background(trackColor)) {
if (animated > 0f) {
Box(Modifier.fillMaxHeight().fillMaxWidth(animated).clip(CircleShape).background(color))
}
}
}
/** 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

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.MenuBook
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.Eco
import androidx.compose.material.icons.filled.Functions
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
@ -101,50 +104,61 @@ fun HomeScreen(vm: AppViewModel) {
item {
Card(
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)) {
Text(
vm.tr("welcome_title"),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(vm.tr("welcome_title"), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(8.dp))
Text(
vm.tr(vm.platform.welcomeKey),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
SmoothProgress(
progress = pct,
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
color = MaterialTheme.colorScheme.onPrimaryContainer,
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.18f),
)
Spacer(Modifier.height(6.dp))
}
Spacer(Modifier.width(16.dp))
ProgressRing(pct, Modifier.size(104.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(vm.fmt("stats_pct_fmt", (pct * 100).toInt()), style = MaterialTheme.typography.titleLarge)
Text(
vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") {
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")) }
items(vm.content.subjects) { s ->
val open = s.bool("open")
items(open) { s ->
val part = subjectSum(vm, s)
NavRow(
title = vm.tr(s.str("key")),
subtitle = if (open) null else vm.tr("stats_locked"),
locked = !open,
leading = { SubjectIcon(s.str("icon"), open) },
onClick = if (open) ({ vm.goPage(s.str("target")) }) else null,
leading = { SubjectIcon(s.str("icon"), true) },
progress = part.doneEx to part.totalEx,
onClick = { vm.goPage(s.str("target")) },
)
}
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)
Card(
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)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AutoAwesome, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
vm.tr("changelog_title"),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.weight(1f),
)
val date = blocks.firstOrNull()?.str("date").orEmpty()
@ -169,7 +187,7 @@ private fun ChangelogCard(vm: AppViewModel) {
Text(
date,
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(
"• $line",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
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 {
val sum = ProgressSum()
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 ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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_year2"), subtitle = vm.tr("net2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { 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_year4"), subtitle = vm.tr("net4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Net4Map) }
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 = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Net2Map)) { vm.go(Route.Net2Map) }
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 = { 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 ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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_year2"), subtitle = vm.tr("hw2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { 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_year4"), subtitle = vm.tr("hw4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Hw4Map) }
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 = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Hw2Map)) { vm.go(Route.Hw2Map) }
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 = { 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 ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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)
}
}
@ -838,7 +916,8 @@ fun DeHomeScreen(vm: AppViewModel) {
NavRow(
title = vm.tr("de_year$year"),
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)) }
}
}
@ -854,7 +933,8 @@ fun EnYearsScreen(vm: AppViewModel) {
NavRow(
title = vm.tr("en_year$year"),
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)) }
}
}
@ -866,10 +946,10 @@ fun OnYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Občanská nauka"), vm.tr("on_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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_year2"), subtitle = vm.tr("on2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { 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_year4"), subtitle = vm.tr("on4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.On4Map) }
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 = { LeadingBadge("2") }, progress = routeProgress(vm, Route.On2Map)) { vm.go(Route.On2Map) }
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 = { 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 ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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_year2"), subtitle = vm.tr("lit2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { 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_year4"), subtitle = vm.tr("lit4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Lit4Map) }
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 = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Lit2Map)) { vm.go(Route.Lit2Map) }
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 = { 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 ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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("mat_year1"), subtitle = vm.tr("mat_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { 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_year3"), subtitle = vm.tr("mat3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { 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("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 = { LeadingBadge("1") }, progress = routeProgress(vm, Route.MatMap)) { vm.go(Route.MatMap) }
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 = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Mat3Map)) { vm.go(Route.Mat3Map) }
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 ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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_year2"), subtitle = vm.tr("cs2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { 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_year4"), subtitle = vm.tr("cs4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Cs4Map) }
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 = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Cs2Map)) { vm.go(Route.Cs2Map) }
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 = { 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 ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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_year2"), subtitle = vm.tr("tg2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { 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_year4"), subtitle = vm.tr("tg4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Tg4Map) }
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 = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Tg2Map)) { vm.go(Route.Tg2Map) }
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 = { 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 ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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_year2"), subtitle = vm.tr("fyz2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { 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_year4"), subtitle = vm.tr("fyz4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Fyz4Map) }
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 = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Fyz2Map)) { vm.go(Route.Fyz2Map) }
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 = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Fyz4Map)) { vm.go(Route.Fyz4Map) }
}
}
}
@ -954,11 +1034,17 @@ fun SciMapScreen(vm: AppViewModel) {
NavRow(
title = vm.tr("Chemie"),
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) }
}
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 ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
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 {
NavRow(
title = vm.tr("Mluvnice"),
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) }
}
item {
NavRow(
title = vm.tr("Maturitní četba"),
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) }
}
}
@ -1045,6 +1137,52 @@ fun BookScreen(vm: AppViewModel, id: String) {
// ---------------------------------------------------------------------------
// 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)
@Composable
fun SlidesScreen(
@ -1095,52 +1233,13 @@ fun SlidesScreen(
},
) { inner ->
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize().padding(inner)) { page ->
val slide = slides[page]
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
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))
}
}
SlideContent(slides[page])
Spacer(Modifier.height(24.dp))
}
}

View file

@ -117,6 +117,7 @@
| search_page | Stránka | Page |
| search_home | Úvod | Welcome |
| 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. |
# Čeština
@ -262,6 +263,8 @@
| cs_run | Spustit | Run |
| cs_reset | Obnovit ukázku | Reset example |
| 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_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. |

View file

@ -50,109 +50,47 @@ enum MacChrome {
static let pageInset: CGFloat = 28
static let cardPadding: CGFloat = 22
static let listRowMinHeight: CGFloat = 52
static let homeTileMin: CGFloat = 240
static let homeTileHeight: CGFloat = 168
static let homeTileMin: CGFloat = 260
static let homeTileHeight: CGFloat = 172
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 {
/// 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.
/// Fixed-height cards in as many columns as fit, centred and capped in width.
struct MacFillGrid<Content: View>: View {
var count: Int
var minWidth: CGFloat = 240
var minHeight: CGFloat = 148
var spacing: CGFloat = 16
var inset: CGFloat = 28
/// Home embeds the grid in its own scroll view. A second scroller here
/// swallows the wheel once a banner (the changelog) makes the page taller.
var scrolls = true
var maxTileWidth: CGFloat = 340
@ViewBuilder var content: (Int) -> Content
var body: some View {
GeometryReader { geo in
let innerW = max(geo.size.width - inset * 2, minWidth)
let available = max(geo.size.height - inset * 2, minHeight)
let cols = MacGrid.columns(width: innerW, height: available, count: count, minWidth: minWidth, minHeight: minHeight, spacing: spacing)
let rows = max(1, Int(ceil(Double(max(count, 1)) / Double(cols))))
let floorH = CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0))
let gridH = max(available, floorH)
let tileH = max(minHeight, (gridH - spacing * CGFloat(max(rows - 1, 0))) / CGFloat(rows))
let tileW = (innerW - spacing * CGFloat(max(cols - 1, 0))) / CGFloat(max(cols, 1))
let lastCount = count - (rows - 1) * cols
let tiles = VStack(spacing: spacing) {
ForEach(0..<rows, id: \.self) { row in
let stretch = row < rows - 1 || lastCount > 1
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
)
let available = max(geo.size.width - inset * 2, minWidth)
let fit = max(1, Int((available + spacing) / (minWidth + spacing)))
let cols = max(1, min(count, fit))
let width = min(available, CGFloat(cols) * maxTileWidth + CGFloat(cols - 1) * spacing)
ScrollView {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: spacing, alignment: .top), count: cols),
alignment: .leading,
spacing: spacing
) {
ForEach(0..<count, id: \.self) { i in
content(i).frame(height: minHeight)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(height: gridH, alignment: .top)
.frame(width: width)
.padding(inset)
.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
.frame(maxWidth: .infinity)
}
}
.softAppear()
}
}
@ -162,45 +100,103 @@ struct MacLinkCard: View {
var badge: String? = nil
var systemImage: String? = nil
var locked = false
var progress: (done: Int, total: Int)? = nil
var body: some View {
VStack(alignment: .leading, spacing: 10) {
let tint = locked ? Color.secondary : Color.accentColor
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center) {
if let badge {
Text(badge)
.font(.title.weight(.bold))
.foregroundStyle(locked ? Color.secondary : Color.accentColor)
}
Group {
if let systemImage {
Image(systemName: systemImage)
.font(.title2.weight(.semibold))
.foregroundStyle(locked ? Color.secondary : Color.accentColor)
.font(.title3.weight(.semibold))
} else if let badge {
Text(badge.hasSuffix(".") ? String(badge.dropLast()) : badge)
.font(.title3.weight(.bold))
.monospacedDigit()
}
}
.foregroundStyle(tint)
.frame(width: 40, height: 40)
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
Spacer(minLength: 0)
if locked {
Image(systemName: "lock.fill")
.foregroundStyle(.secondary)
}
Image(systemName: locked ? "lock.fill" : "chevron.right")
.font(.body.weight(.semibold))
.foregroundStyle(.tertiary)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title2.weight(.semibold))
.font(.title3.weight(.semibold))
.foregroundStyle(locked ? .secondary : .primary)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(22)
.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)
}
}
}
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.softSurface(cornerRadius: 22)
.softSurface(cornerRadius: 20)
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
#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 {
var spacing: CGFloat = 6
@ -574,17 +570,30 @@ extension View {
#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 {
frame(maxWidth: .infinity, alignment: .leading)
#if os(macOS)
frame(maxWidth: MacChrome.readableWidth, alignment: .leading)
.padding(.horizontal, MacChrome.pageInset)
.padding(.vertical, 20)
.padding(.vertical, 28)
.frame(maxWidth: .infinity)
#else
frame(maxWidth: .infinity, alignment: .leading)
.padding()
#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
/// glass sidebar instead of looking like a second source list.
@ViewBuilder
@ -636,7 +645,8 @@ struct BottomAction<Extra: View>: View {
}())
#if os(macOS)
.padding(.top, 12)
.padding(.bottom, 10)
.padding(.bottom, 14)
.frame(maxWidth: MacChrome.actionWidth)
.frame(maxWidth: .infinity)
#else
.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 {
let title: String
var subtitle: String? = nil
var progress: (done: Int, total: Int)? = nil
var body: some View {
VStack(alignment: .leading, spacing: 3) {
@ -665,6 +676,25 @@ struct ListRowLabel: View {
#endif
.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 {
let p = vm.palette
Group {
#if os(macOS)
GeometryReader { geo in
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)
.detailScreen(title, subtitle: sub)

View file

@ -8,6 +8,8 @@ struct MapNode: Identifiable {
let current: Bool
var finish: Bool = false
var destination: Route?
/// Glyph for a node that has no number, such as a side branch.
var symbol: String? = nil
}
private struct NodePos {
@ -256,6 +258,8 @@ struct PathMap: View {
.overlay {
if node.locked {
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 {
@ -298,6 +302,13 @@ struct PathMap: View {
#else
.font(.headline.weight(.black))
#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")
#if os(macOS)
@ -315,6 +326,14 @@ struct PathMap: View {
.font(.headline.weight(.black))
#endif
.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)

File diff suppressed because it is too large Load diff