diff --git a/android/desktop/src/main/kotlin/org/maturita/maturita/desktop/Main.kt b/android/desktop/src/main/kotlin/org/maturita/maturita/desktop/Main.kt index 98d77e9..aa9a704 100644 --- a/android/desktop/src/main/kotlin/org/maturita/maturita/desktop/Main.kt +++ b/android/desktop/src/main/kotlin/org/maturita/maturita/desktop/Main.kt @@ -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()) } diff --git a/android/shared/src/org/maturita/maturita/ui/CodeLesson.kt b/android/shared/src/org/maturita/maturita/ui/CodeLesson.kt index c51b2ce..3e8640e 100644 --- a/android/shared/src/org/maturita/maturita/ui/CodeLesson.kt +++ b/android/shared/src/org/maturita/maturita/ui/CodeLesson.kt @@ -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) - 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()), - ) + 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), + 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, -) { - 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")) } +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, + ) { + 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) + } + 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]*?\\*/"), + ) + } +} diff --git a/android/shared/src/org/maturita/maturita/ui/Components.kt b/android/shared/src/org/maturita/maturita/ui/Components.kt index 6798891..6bdde68 100644 --- a/android/shared/src/org/maturita/maturita/ui/Components.kt +++ b/android/shared/src/org/maturita/maturita/ui/Components.kt @@ -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,30 +187,48 @@ 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 = { - if (showSearch) { - IconButton(onClick = { vm.searchOpen = true }) { - Icon(Icons.Filled.Search, contentDescription = vm.tr("search_placeholder")) - } - } - }, - scrollBehavior = scroll, - ) - }, - bottomBar = { AppNavBar(vm) }, - ) { inner -> - LazyColumn( - contentPadding = PaddingValues( - top = inner.calculateTopPadding(), - bottom = inner.calculateBottomPadding() + 16.dp, - ), - ) { content() } + 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(), + bottom = inner.calculateBottomPadding() + 16.dp, + ), + ) { content() } + } + } } } @@ -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? = 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 diff --git a/android/shared/src/org/maturita/maturita/ui/Screens.kt b/android/shared/src/org/maturita/maturita/ui/Screens.kt index 761a024..39404c3 100644 --- a/android/shared/src/org/maturita/maturita/ui/Screens.kt +++ b/android/shared/src/org/maturita/maturita/ui/Screens.kt @@ -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, - ) - Spacer(Modifier.height(8.dp)) - Text( - vm.tr(vm.platform.welcomeKey), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), - ) - 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)) - Text( - vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), - ) + 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.onSurfaceVariant, + ) + } + 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.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? { + val c = vm.content + val p = vm.progress + fun tally(lessons: List, 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)) } } diff --git a/content/aplikace/texty.md b/content/aplikace/texty.md index 3668457..34e1048 100644 --- a/content/aplikace/texty.md +++ b/content/aplikace/texty.md @@ -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. | diff --git a/ios/Graduately/UI/Components.swift b/ios/Graduately/UI/Components.swift index a4bd6fe..ec5bd56 100644 --- a/ios/Graduately/UI/Components.swift +++ b/ios/Graduately/UI/Components.swift @@ -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: 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.. 1 - HStack(spacing: spacing) { - ForEach(0.. 0 { + HStack(spacing: 10) { + ProgressBar(value: Double(progress.done) / Double(progress.total)) + Text("\(progress.done) / \(progress.total)") + .font(.subheadline.monospacedDigit()) .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) - .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: 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,15 +570,28 @@ 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) + #if os(macOS) + frame(maxWidth: MacChrome.readableWidth, alignment: .leading) .padding(.horizontal, MacChrome.pageInset) - .padding(.vertical, 20) - #else + .padding(.vertical, 28) + .frame(maxWidth: .infinity) + #else + frame(maxWidth: .infinity, alignment: .leading) .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 @@ -636,7 +645,8 @@ struct BottomAction: 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: 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) } } } diff --git a/ios/Graduately/UI/Exercises.swift b/ios/Graduately/UI/Exercises.swift index e71f134..fb4ccea 100644 --- a/ios/Graduately/UI/Exercises.swift +++ b/ios/Graduately/UI/Exercises.swift @@ -12,21 +12,9 @@ struct ExerciseScaffold: 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 + ScrollView { + VStack(alignment: .leading, spacing: 8) { content() } + .exerciseContent() } .scrollDismissesKeyboard(.interactively) .detailScreen(title, subtitle: sub) diff --git a/ios/Graduately/UI/PathMap.swift b/ios/Graduately/UI/PathMap.swift index 3271b2e..5c7e4e4 100644 --- a/ios/Graduately/UI/PathMap.swift +++ b/ios/Graduately/UI/PathMap.swift @@ -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) diff --git a/ios/Graduately/UI/Screens.swift b/ios/Graduately/UI/Screens.swift index e2f1ebb..02230cf 100644 --- a/ios/Graduately/UI/Screens.swift +++ b/ios/Graduately/UI/Screens.swift @@ -1,10 +1,10 @@ import SwiftUI +#if os(macOS) +import AppKit +#endif struct PracticeHome: View { @ObservedObject var vm: AppModel - #if os(macOS) - @State private var homeHeader: CGFloat = 280 - #endif var body: some View { #if os(macOS) @@ -18,111 +18,124 @@ struct PracticeHome: View { private var macHome: some View { let sum = summarize(vm.content, vm.progress) let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx) - let subjects = vm.content.subjects - return GeometryReader { geo in - let width = max(geo.size.width - MacChrome.pageInset * 2, MacChrome.homeTileMin) - let offered = max(geo.size.height - homeHeader - 20 - MacChrome.pageInset * 2, MacChrome.homeTileHeight) - let minGrid = MacGrid.contentHeight( - width: width, - height: offered, - count: subjects.count, - minWidth: MacChrome.homeTileMin, - minHeight: MacChrome.homeTileHeight, - spacing: 16 - ) - let gridH = max(minGrid, geo.size.height - homeHeader - 20 - MacChrome.pageInset * 2) - ScrollView { - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .leading, spacing: 16) { - if vm.showChangelog { - ChangelogCard(vm: vm) + let open = vm.content.subjects.filter { $0.bool("open") } + let soon = vm.content.subjects.filter { !$0.bool("open") } + return ScrollView { + VStack(alignment: .leading, spacing: 28) { + if vm.showChangelog { + ChangelogCard(vm: vm) + } + welcomeCard(sum: sum, pct: pct) + VStack(alignment: .leading, spacing: 14) { + Text(vm.tr("subjects_title")) + .font(.title2.weight(.semibold)) + LazyVGrid( + columns: [GridItem(.adaptive(minimum: MacChrome.homeTileMin), spacing: 16, alignment: .top)], + alignment: .leading, + spacing: 16 + ) { + ForEach(Array(open.enumerated()), id: \.offset) { _, s in + subjectTile(s) } - GlassCard { - VStack(alignment: .leading, spacing: 12) { - Text(vm.tr("welcome_title")) - .font(.largeTitle.bold()) - Text(vm.tr(Self.welcomeBodyKey)) - .font(.title3) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - ProgressView(value: pct) - .animation(Motion.settle, value: pct) - Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx)) - .font(.subheadline) + } + } + if !soon.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text(vm.tr("subjects_soon")) + .font(.headline) + .foregroundStyle(.secondary) + FlowLayout(spacing: 10) { + ForEach(Array(soon.enumerated()), id: \.offset) { _, s in + Label(vm.tr(s.str("key")), systemImage: "lock.fill") + .font(.body) .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(Color.primary.opacity(0.05), in: Capsule()) } } - Text(vm.tr("subjects_title")) - .font(.title2.weight(.semibold)) } - .fixedSize(horizontal: false, vertical: true) - .background { - GeometryReader { proxy in - Color.clear.preference(key: MacHeaderHeight.self, value: proxy.size.height) - } - } - MacFillGrid( - count: subjects.count, - minWidth: MacChrome.homeTileMin, - minHeight: MacChrome.homeTileHeight, - spacing: 16, - inset: 0, - scrolls: false - ) { index in - subjectTile(subjects[index]) - } - .frame(height: gridH) } - .padding(MacChrome.pageInset) - .frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .topLeading) } - .onPreferenceChange(MacHeaderHeight.self) { homeHeader = $0 } + .padding(MacChrome.pageInset) + .macPage() } .navigationTitle("Graduately") } - @ViewBuilder - private func subjectTile(_ s: J) -> some View { - let open = s.bool("open") - let dest = s.strOrNull("target").flatMap(routeFromPage) - let part = subjectSum(vm, s) - let progress = open && part.totalEx > 0 ? Double(part.doneEx) / Double(part.totalEx) : 0 - Group { - if open, let dest, dest != .subjects, dest != .welcome { - NavigationLink(value: dest) { - subjectTileBody(s, open: true, progress: progress, part: part) + private func welcomeCard(sum: ProgressSum, pct: Double) -> some View { + GlassCard { + HStack(alignment: .center, spacing: 32) { + VStack(alignment: .leading, spacing: 12) { + Text(vm.tr("welcome_title")) + .font(.system(size: 34, weight: .bold)) + .fixedSize(horizontal: false, vertical: true) + Text(vm.tr(Self.welcomeBodyKey)) + .font(.title3) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - .buttonStyle(PressScaleStyle()) - } else { - subjectTileBody(s, open: false, progress: 0, part: part) + .frame(maxWidth: .infinity, alignment: .leading) + ProgressRing(value: pct) { + VStack(spacing: 2) { + Text(vm.fmt("stats_pct_fmt", Int((pct * 100).rounded(.down)))) + .font(.system(size: 26, weight: .bold, design: .rounded)) + .monospacedDigit() + Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx)) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .frame(width: 132, height: 132) } + .padding(.vertical, 6) } } - private func subjectTileBody(_ s: J, open: Bool, progress: Double, part: ProgressSum) -> some View { - VStack(alignment: .leading, spacing: 16) { - SubjectIcon(icon: s.str("icon"), open: open, size: 44) + @ViewBuilder + private func subjectTile(_ s: J) -> some View { + let dest = s.strOrNull("target").flatMap(routeFromPage) + if let dest, dest != .subjects, dest != .welcome { + NavigationLink(value: dest) { + subjectTileBody(s) + } + .buttonStyle(PressScaleStyle()) + } else { + subjectTileBody(s) + } + } + + private func subjectTileBody(_ s: J) -> some View { + let part = subjectSum(vm, s) + let progress = part.totalEx > 0 ? Double(part.doneEx) / Double(part.totalEx) : 0 + return VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .center) { + SubjectIcon(icon: s.str("icon"), open: true, size: 40) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundStyle(.tertiary) + } Text(vm.tr(s.str("key"))) .font(.title3.weight(.semibold)) - .foregroundStyle(open ? .primary : .secondary) + .foregroundStyle(.primary) .multilineTextAlignment(.leading) + .lineLimit(2) Spacer(minLength: 0) - if open && part.totalEx > 0 { - ProgressView(value: progress) - .animation(Motion.settle, value: progress) + ProgressBar(value: progress) + HStack { Text(vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx)) - .font(.subheadline) - .foregroundStyle(.secondary) - .monospacedDigit() - } else if !open { - Text(vm.tr("stats_locked")) - .font(.subheadline) - .foregroundStyle(.secondary) + Spacer(minLength: 0) + Text(vm.fmt("stats_pct_fmt", Int((progress * 100).rounded(.down)))) } + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) } - .padding(22) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(20) + .frame(maxWidth: .infinity, alignment: .topLeading) + .frame(height: MacChrome.homeTileHeight) .softSurface(cornerRadius: 22) + .contentShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) } #endif @@ -160,16 +173,28 @@ struct PracticeHome: View { } Section(vm.tr("subjects_title")) { - ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in - let open = s.bool("open") + ForEach(Array(vm.content.subjects.filter { $0.bool("open") }.enumerated()), id: \.offset) { _, s in let dest = s.strOrNull("target").flatMap(routeFromPage) - if open, let dest, dest != .subjects, dest != .welcome { + if let dest, dest != .subjects, dest != .welcome { NavigationLink(value: dest) { subjectLabel(s) } } else { subjectLabel(s) - .foregroundStyle(.secondary) + } + } + } + + let soon = vm.content.subjects.filter { !$0.bool("open") } + if !soon.isEmpty { + Section(vm.tr("subjects_soon")) { + ForEach(Array(soon.enumerated()), id: \.offset) { _, s in + Label { + Text(vm.tr(s.str("key"))) + } icon: { + SubjectIcon(icon: s.str("icon"), open: false) + } + .foregroundStyle(.secondary) } } } @@ -179,15 +204,17 @@ struct PracticeHome: View { } private func subjectLabel(_ s: J) -> some View { - Label { - VStack(alignment: .leading, spacing: 2) { + let part = subjectSum(vm, s) + return Label { + VStack(alignment: .leading, spacing: 6) { Text(vm.tr(s.str("key"))) - if !s.bool("open") { - Text(vm.tr("stats_locked")).font(.caption).foregroundStyle(.secondary) + if part.totalEx > 0 { + ProgressLine(done: part.doneEx, total: part.totalEx) } } + .padding(.vertical, 2) } icon: { - SubjectIcon(icon: s.str("icon"), open: s.bool("open")) + SubjectIcon(icon: s.str("icon"), open: true) } } #endif @@ -423,7 +450,8 @@ struct SettingsScreen: View { } } .padding(MacChrome.pageInset) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: MacChrome.contentWidth, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle(vm.tr("settings_title")) } @@ -566,7 +594,8 @@ struct StatsScreen: View { } } .padding(MacChrome.pageInset) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: MacChrome.contentWidth, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle(vm.tr("stats_title")) } @@ -664,6 +693,68 @@ struct StatsScreen: View { } } +/// Done and total exercises behind one card of a years screen, or nil when there is no count. +func routeProgress(_ vm: AppModel, _ r: Route) -> (done: Int, total: Int)? { + let c = vm.content + let p = vm.progress + func tally(_ lessons: [J], _ done: (Int) -> Bool) -> (done: Int, total: Int) { + (lessons.filter { done($0.int("id")) }.count, lessons.count) + } + switch r { + case .netMap: return tally(c.netLessons, p.netDone) + case .net2Map: return tally(c.itLessons("net2")) { p.courseDone("net2", $0) } + case .net3Map: return tally(c.itLessons("net3")) { p.courseDone("net3", $0) } + case .net4Map: return tally(c.itLessons("net4")) { p.courseDone("net4", $0) } + case .hwMap: return tally(c.hw, p.hwDone) + case .hw2Map: return tally(c.itLessons("hw2")) { p.courseDone("hw2", $0) } + case .hw3Map: return tally(c.itLessons("hw3")) { p.courseDone("hw3", $0) } + case .hw4Map: return tally(c.itLessons("hw4")) { p.courseDone("hw4", $0) } + case .onMap: return tally(c.on, p.onDone) + case .on2Map: return tally(c.on2, p.on2Done) + case .on3Map: return tally(c.on3, p.on3Done) + case .on4Map: return tally(c.on4, p.on4Done) + case .enMap(let year): return tally(c.enYear(year)) { p.enDone(year, $0) } + case .deMap(let year): return tally(c.deYear(year)) { p.deDone(year, $0) } + case .roadmap: + var done = 0 + var total = 0 + for u in c.german where u.bool("unlocked") { + let n = u.strs("names").count + total += n + done += (0.. ProgressSum { var sum = ProgressSum() switch s.str("target") { @@ -795,6 +886,7 @@ struct SearchScreen: View { } } .padding(MacChrome.pageInset) + .macPage() } } .navigationTitle(vm.tr("search")) @@ -1108,7 +1200,7 @@ struct RoadmapScreen: View { vm.progress.germanDone(u.int("id"), $0 + 1) } return MapNode( - id: u.str("page"), + id: "unit-\(i)", label: u.str("title"), locked: !unlocked, done: done, @@ -1186,9 +1278,10 @@ struct UnitMapScreen: View { id: "vocab-\(unitId)", label: vm.tr("Vokabeltraining"), locked: false, - done: false, + done: vm.progress.vocabDone(unitId), current: false, - destination: .vocab(unitId) + destination: .vocab(unitId), + symbol: "character.book.closed.fill" ) ) : nil @@ -1256,9 +1349,9 @@ struct NetYearsScreen: View { ("net_year3", "net3_sub", .net3Map), ("net_year4", "net4_sub", .net4Map), ] - MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: years[i].2) { - MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).") + MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2)) } .buttonStyle(PressScaleStyle()) } @@ -1267,16 +1360,16 @@ struct NetYearsScreen: View { #else List { NavigationLink(value: Route.netMap) { - ListRowLabel(title: vm.tr("net_year1"), subtitle: vm.tr("net_sub")) + ListRowLabel(title: vm.tr("net_year1"), subtitle: vm.tr("net_sub"), progress: routeProgress(vm, Route.netMap)) } NavigationLink(value: Route.net2Map) { - ListRowLabel(title: vm.tr("net_year2"), subtitle: vm.tr("net2_sub")) + ListRowLabel(title: vm.tr("net_year2"), subtitle: vm.tr("net2_sub"), progress: routeProgress(vm, Route.net2Map)) } NavigationLink(value: Route.net3Map) { - ListRowLabel(title: vm.tr("net_year3"), subtitle: vm.tr("net3_sub")) + ListRowLabel(title: vm.tr("net_year3"), subtitle: vm.tr("net3_sub"), progress: routeProgress(vm, Route.net3Map)) } NavigationLink(value: Route.net4Map) { - ListRowLabel(title: vm.tr("net_year4"), subtitle: vm.tr("net4_sub")) + ListRowLabel(title: vm.tr("net_year4"), subtitle: vm.tr("net4_sub"), progress: routeProgress(vm, Route.net4Map)) } } .navigationTitle(vm.tr("net_years_title")) @@ -1297,9 +1390,9 @@ struct HwYearsScreen: View { ("hw_year3", "hw3_sub", .hw3Map), ("hw_year4", "hw4_sub", .hw4Map), ] - MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: years[i].2) { - MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).") + MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2)) } .buttonStyle(PressScaleStyle()) } @@ -1308,16 +1401,16 @@ struct HwYearsScreen: View { #else List { NavigationLink(value: Route.hwMap) { - ListRowLabel(title: vm.tr("hw_year1"), subtitle: vm.tr("hw_sub")) + ListRowLabel(title: vm.tr("hw_year1"), subtitle: vm.tr("hw_sub"), progress: routeProgress(vm, Route.hwMap)) } NavigationLink(value: Route.hw2Map) { - ListRowLabel(title: vm.tr("hw_year2"), subtitle: vm.tr("hw2_sub")) + ListRowLabel(title: vm.tr("hw_year2"), subtitle: vm.tr("hw2_sub"), progress: routeProgress(vm, Route.hw2Map)) } NavigationLink(value: Route.hw3Map) { - ListRowLabel(title: vm.tr("hw_year3"), subtitle: vm.tr("hw3_sub")) + ListRowLabel(title: vm.tr("hw_year3"), subtitle: vm.tr("hw3_sub"), progress: routeProgress(vm, Route.hw3Map)) } NavigationLink(value: Route.hw4Map) { - ListRowLabel(title: vm.tr("hw_year4"), subtitle: vm.tr("hw4_sub")) + ListRowLabel(title: vm.tr("hw_year4"), subtitle: vm.tr("hw4_sub"), progress: routeProgress(vm, Route.hw4Map)) } } .navigationTitle(vm.tr("Technické vybavení")) @@ -1339,9 +1432,9 @@ struct DeHomeScreen: View { ("3", "de_year3", "de_y3_sub", .deMap(3)), ("4", "de_year4", "de_y4_sub", .deMap(4)), ] - MacFillGrid(count: rows.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: rows.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: rows[i].3) { - MacLinkCard(title: vm.tr(rows[i].1), subtitle: vm.tr(rows[i].2), badge: rows[i].0) + MacLinkCard(title: vm.tr(rows[i].1), subtitle: vm.tr(rows[i].2), badge: rows[i].0, progress: routeProgress(vm, rows[i].3)) } .buttonStyle(PressScaleStyle()) } @@ -1350,11 +1443,11 @@ struct DeHomeScreen: View { #else List { NavigationLink(value: Route.roadmap) { - ListRowLabel(title: vm.tr("de_book_title"), subtitle: vm.tr("de_book_sub")) + ListRowLabel(title: vm.tr("de_book_title"), subtitle: vm.tr("de_book_sub"), progress: routeProgress(vm, Route.roadmap)) } ForEach(1...4, id: \.self) { year in NavigationLink(value: Route.deMap(year)) { - ListRowLabel(title: vm.tr("de_year\(year)"), subtitle: vm.tr("de_y\(year)_sub")) + ListRowLabel(title: vm.tr("de_year\(year)"), subtitle: vm.tr("de_y\(year)_sub"), progress: routeProgress(vm, Route.deMap(year))) } } } @@ -1375,9 +1468,9 @@ struct EnYearsScreen: View { ("en_year3", "en_y3_sub", .enMap(3)), ("en_year4", "en_y4_sub", .enMap(4)), ] - MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: years[i].2) { - MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).") + MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2)) } .buttonStyle(PressScaleStyle()) } @@ -1387,7 +1480,7 @@ struct EnYearsScreen: View { List { ForEach(1...4, id: \.self) { year in NavigationLink(value: Route.enMap(year)) { - ListRowLabel(title: vm.tr("en_year\(year)"), subtitle: vm.tr("en_y\(year)_sub")) + ListRowLabel(title: vm.tr("en_year\(year)"), subtitle: vm.tr("en_y\(year)_sub"), progress: routeProgress(vm, Route.enMap(year))) } } } @@ -1409,9 +1502,9 @@ struct OnYearsScreen: View { ("on_year3", "on3_sub", .on3Map), ("on_year4", "on4_sub", .on4Map), ] - MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: years[i].2) { - MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).") + MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2)) } .buttonStyle(PressScaleStyle()) } @@ -1420,16 +1513,16 @@ struct OnYearsScreen: View { #else List { NavigationLink(value: Route.onMap) { - ListRowLabel(title: vm.tr("on_year1"), subtitle: vm.tr("on_sub")) + ListRowLabel(title: vm.tr("on_year1"), subtitle: vm.tr("on_sub"), progress: routeProgress(vm, Route.onMap)) } NavigationLink(value: Route.on2Map) { - ListRowLabel(title: vm.tr("on_year2"), subtitle: vm.tr("on2_sub")) + ListRowLabel(title: vm.tr("on_year2"), subtitle: vm.tr("on2_sub"), progress: routeProgress(vm, Route.on2Map)) } NavigationLink(value: Route.on3Map) { - ListRowLabel(title: vm.tr("on_year3"), subtitle: vm.tr("on3_sub")) + ListRowLabel(title: vm.tr("on_year3"), subtitle: vm.tr("on3_sub"), progress: routeProgress(vm, Route.on3Map)) } NavigationLink(value: Route.on4Map) { - ListRowLabel(title: vm.tr("on_year4"), subtitle: vm.tr("on4_sub")) + ListRowLabel(title: vm.tr("on_year4"), subtitle: vm.tr("on4_sub"), progress: routeProgress(vm, Route.on4Map)) } } .navigationTitle(vm.tr("Občanská nauka")) @@ -1575,12 +1668,15 @@ struct SlidesScreen: View { .padding(.horizontal) .padding(.top, 8) .padding(.bottom, 4) + #if os(macOS) + .macPage(MacChrome.actionWidth) + #endif } } private func slidePage(_ slide: J) -> some View { ScrollView { - slideStack(slide) + SlideBody(slide: slide) .padding(.horizontal, slideInset) .padding(.top, 22) .padding(.bottom, 28) @@ -1596,6 +1692,15 @@ struct SlidesScreen: View { 16 #endif } +} + +/// One explanation slide: label, title, tip and numbered points. +struct SlideBody: View { + let slide: J + + var body: some View { + slideStack(slide) + } private func slideStack(_ slide: J) -> some View { VStack(alignment: .leading, spacing: 18) { @@ -1724,9 +1829,9 @@ struct LitYearsScreen: View { ("lit_year3", "lit3_sub", .lit3Map), ("lit_year4", "lit4_sub", .lit4Map), ] - MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: years[i].2) { - MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).") + MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2)) } .buttonStyle(PressScaleStyle()) } @@ -1735,16 +1840,16 @@ struct LitYearsScreen: View { #else List { NavigationLink(value: Route.litMap) { - ListRowLabel(title: vm.tr("lit_year1"), subtitle: vm.tr("lit_sub")) + ListRowLabel(title: vm.tr("lit_year1"), subtitle: vm.tr("lit_sub"), progress: routeProgress(vm, Route.litMap)) } NavigationLink(value: Route.lit2Map) { - ListRowLabel(title: vm.tr("lit_year2"), subtitle: vm.tr("lit2_sub")) + ListRowLabel(title: vm.tr("lit_year2"), subtitle: vm.tr("lit2_sub"), progress: routeProgress(vm, Route.lit2Map)) } NavigationLink(value: Route.lit3Map) { - ListRowLabel(title: vm.tr("lit_year3"), subtitle: vm.tr("lit3_sub")) + ListRowLabel(title: vm.tr("lit_year3"), subtitle: vm.tr("lit3_sub"), progress: routeProgress(vm, Route.lit3Map)) } NavigationLink(value: Route.lit4Map) { - ListRowLabel(title: vm.tr("lit_year4"), subtitle: vm.tr("lit4_sub")) + ListRowLabel(title: vm.tr("lit_year4"), subtitle: vm.tr("lit4_sub"), progress: routeProgress(vm, Route.lit4Map)) } } .navigationTitle(vm.tr("Literatura")) @@ -1766,9 +1871,9 @@ struct MatYearsScreen: View { ("mat_year3", "mat3_sub", .mat3Map, "3."), ("mat_year4", "mat4_sub", .mat4Map, "4."), ] - MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: years[i].2) { - MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: years[i].3) + MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: years[i].3, progress: routeProgress(vm, years[i].2)) } .buttonStyle(PressScaleStyle()) } @@ -1777,19 +1882,19 @@ struct MatYearsScreen: View { #else List { NavigationLink(value: Route.mat0Map) { - ListRowLabel(title: vm.tr("mat0_year"), subtitle: vm.tr("mat0_sub")) + ListRowLabel(title: vm.tr("mat0_year"), subtitle: vm.tr("mat0_sub"), progress: routeProgress(vm, Route.mat0Map)) } NavigationLink(value: Route.matMap) { - ListRowLabel(title: vm.tr("mat_year1"), subtitle: vm.tr("mat_sub")) + ListRowLabel(title: vm.tr("mat_year1"), subtitle: vm.tr("mat_sub"), progress: routeProgress(vm, Route.matMap)) } NavigationLink(value: Route.mat2Map) { - ListRowLabel(title: vm.tr("mat_year2"), subtitle: vm.tr("mat2_sub")) + ListRowLabel(title: vm.tr("mat_year2"), subtitle: vm.tr("mat2_sub"), progress: routeProgress(vm, Route.mat2Map)) } NavigationLink(value: Route.mat3Map) { - ListRowLabel(title: vm.tr("mat_year3"), subtitle: vm.tr("mat3_sub")) + ListRowLabel(title: vm.tr("mat_year3"), subtitle: vm.tr("mat3_sub"), progress: routeProgress(vm, Route.mat3Map)) } NavigationLink(value: Route.mat4Map) { - ListRowLabel(title: vm.tr("mat_year4"), subtitle: vm.tr("mat4_sub")) + ListRowLabel(title: vm.tr("mat_year4"), subtitle: vm.tr("mat4_sub"), progress: routeProgress(vm, Route.mat4Map)) } } .navigationTitle(vm.tr("Matematika")) @@ -1810,9 +1915,9 @@ struct CsYearsScreen: View { ("cs_year3", "cs3_sub", .cs3Map), ("cs_year4", "cs4_sub", .cs4Map), ] - MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: years[i].2) { - MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).") + MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2)) } .buttonStyle(PressScaleStyle()) } @@ -1821,16 +1926,16 @@ struct CsYearsScreen: View { #else List { NavigationLink(value: Route.csMap) { - ListRowLabel(title: vm.tr("cs_year1"), subtitle: vm.tr("cs_sub")) + ListRowLabel(title: vm.tr("cs_year1"), subtitle: vm.tr("cs_sub"), progress: routeProgress(vm, Route.csMap)) } NavigationLink(value: Route.cs2Map) { - ListRowLabel(title: vm.tr("cs_year2"), subtitle: vm.tr("cs2_sub")) + ListRowLabel(title: vm.tr("cs_year2"), subtitle: vm.tr("cs2_sub"), progress: routeProgress(vm, Route.cs2Map)) } NavigationLink(value: Route.cs3Map) { - ListRowLabel(title: vm.tr("cs_year3"), subtitle: vm.tr("cs3_sub")) + ListRowLabel(title: vm.tr("cs_year3"), subtitle: vm.tr("cs3_sub"), progress: routeProgress(vm, Route.cs3Map)) } NavigationLink(value: Route.cs4Map) { - ListRowLabel(title: vm.tr("cs_year4"), subtitle: vm.tr("cs4_sub")) + ListRowLabel(title: vm.tr("cs_year4"), subtitle: vm.tr("cs4_sub"), progress: routeProgress(vm, Route.cs4Map)) } } .navigationTitle(vm.tr("Programování")) @@ -1851,9 +1956,9 @@ struct TgYearsScreen: View { ("tg_year3", "tg3_sub", .tg3Map), ("tg_year4", "tg4_sub", .tg4Map), ] - MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: years[i].2) { - MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).") + MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2)) } .buttonStyle(PressScaleStyle()) } @@ -1862,16 +1967,16 @@ struct TgYearsScreen: View { #else List { NavigationLink(value: Route.tgMap) { - ListRowLabel(title: vm.tr("tg_year1"), subtitle: vm.tr("tg_sub")) + ListRowLabel(title: vm.tr("tg_year1"), subtitle: vm.tr("tg_sub"), progress: routeProgress(vm, Route.tgMap)) } NavigationLink(value: Route.tg2Map) { - ListRowLabel(title: vm.tr("tg_year2"), subtitle: vm.tr("tg2_sub")) + ListRowLabel(title: vm.tr("tg_year2"), subtitle: vm.tr("tg2_sub"), progress: routeProgress(vm, Route.tg2Map)) } NavigationLink(value: Route.tg3Map) { - ListRowLabel(title: vm.tr("tg_year3"), subtitle: vm.tr("tg3_sub")) + ListRowLabel(title: vm.tr("tg_year3"), subtitle: vm.tr("tg3_sub"), progress: routeProgress(vm, Route.tg3Map)) } NavigationLink(value: Route.tg4Map) { - ListRowLabel(title: vm.tr("tg_year4"), subtitle: vm.tr("tg4_sub")) + ListRowLabel(title: vm.tr("tg_year4"), subtitle: vm.tr("tg4_sub"), progress: routeProgress(vm, Route.tg4Map)) } } .navigationTitle(vm.tr("Technická grafika")) @@ -1892,9 +1997,9 @@ struct FyzYearsScreen: View { ("fyz_year3", "fyz3_sub", .fyz3Map), ("fyz_year4", "fyz4_sub", .fyz4Map), ] - MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: years.count, minWidth: 260, minHeight: 200) { i in NavigationLink(value: years[i].2) { - MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).") + MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).", progress: routeProgress(vm, years[i].2)) } .buttonStyle(PressScaleStyle()) } @@ -1903,16 +2008,16 @@ struct FyzYearsScreen: View { #else List { NavigationLink(value: Route.fyzMap) { - ListRowLabel(title: vm.tr("fyz_year1"), subtitle: vm.tr("fyz_sub")) + ListRowLabel(title: vm.tr("fyz_year1"), subtitle: vm.tr("fyz_sub"), progress: routeProgress(vm, Route.fyzMap)) } NavigationLink(value: Route.fyz2Map) { - ListRowLabel(title: vm.tr("fyz_year2"), subtitle: vm.tr("fyz2_sub")) + ListRowLabel(title: vm.tr("fyz_year2"), subtitle: vm.tr("fyz2_sub"), progress: routeProgress(vm, Route.fyz2Map)) } NavigationLink(value: Route.fyz3Map) { - ListRowLabel(title: vm.tr("fyz_year3"), subtitle: vm.tr("fyz3_sub")) + ListRowLabel(title: vm.tr("fyz_year3"), subtitle: vm.tr("fyz3_sub"), progress: routeProgress(vm, Route.fyz3Map)) } NavigationLink(value: Route.fyz4Map) { - ListRowLabel(title: vm.tr("fyz_year4"), subtitle: vm.tr("fyz4_sub")) + ListRowLabel(title: vm.tr("fyz_year4"), subtitle: vm.tr("fyz4_sub"), progress: routeProgress(vm, Route.fyz4Map)) } } .navigationTitle(vm.tr("Fyzika")) @@ -1927,15 +2032,15 @@ struct SciMapScreen: View { var body: some View { #if os(macOS) - MacFillGrid(count: 2, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: 2, minWidth: 260, minHeight: 200) { i in if i == 0 { NavigationLink(value: Route.chemMap) { - MacLinkCard(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub"), systemImage: "flask.fill") + MacLinkCard(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub"), systemImage: "flask.fill", progress: routeProgress(vm, .chemMap)) } .buttonStyle(PressScaleStyle()) } else { NavigationLink(value: Route.bioMap) { - MacLinkCard(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub"), systemImage: "leaf.fill") + MacLinkCard(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub"), systemImage: "leaf.fill", progress: routeProgress(vm, .bioMap)) } .buttonStyle(PressScaleStyle()) } @@ -1946,14 +2051,14 @@ struct SciMapScreen: View { List { NavigationLink(value: Route.chemMap) { Label { - ListRowLabel(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub")) + ListRowLabel(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub"), progress: routeProgress(vm, .chemMap)) } icon: { Image(systemName: "flask.fill") } } NavigationLink(value: Route.bioMap) { Label { - ListRowLabel(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub")) + ListRowLabel(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub"), progress: routeProgress(vm, .bioMap)) } icon: { Image(systemName: "leaf.fill") } @@ -1971,21 +2076,21 @@ struct CzechMapScreen: View { var body: some View { #if os(macOS) - MacFillGrid(count: 3, minWidth: 260, minHeight: 180) { i in + MacFillGrid(count: 3, minWidth: 260, minHeight: 200) { i in switch i { case 1: NavigationLink(value: Route.mluvnice) { - MacLinkCard(title: vm.tr("Mluvnice"), systemImage: "textformat") + MacLinkCard(title: vm.tr("Mluvnice"), subtitle: vm.tr("mluv_sub"), systemImage: "textformat", progress: routeProgress(vm, .mluvnice)) } .buttonStyle(PressScaleStyle()) case 2: NavigationLink(value: Route.readingList) { - MacLinkCard(title: vm.tr("Maturitní četba"), systemImage: "books.vertical") + MacLinkCard(title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub"), systemImage: "books.vertical") } .buttonStyle(PressScaleStyle()) default: NavigationLink(value: Route.litYears) { - MacLinkCard(title: vm.tr("Literatura"), systemImage: "book.closed") + MacLinkCard(title: vm.tr("Literatura"), subtitle: vm.tr("lit_years_sub"), systemImage: "book.closed", progress: routeProgress(vm, .litYears)) } .buttonStyle(PressScaleStyle()) } @@ -1996,21 +2101,21 @@ struct CzechMapScreen: View { List { NavigationLink(value: Route.litYears) { Label { - Text(vm.tr("Literatura")) + ListRowLabel(title: vm.tr("Literatura"), subtitle: vm.tr("lit_years_sub"), progress: routeProgress(vm, .litYears)) } icon: { Image(systemName: "book.closed") } } NavigationLink(value: Route.mluvnice) { Label { - Text(vm.tr("Mluvnice")) + ListRowLabel(title: vm.tr("Mluvnice"), subtitle: vm.tr("mluv_sub"), progress: routeProgress(vm, .mluvnice)) } icon: { Image(systemName: "textformat") } } NavigationLink(value: Route.readingList) { Label { - Text(vm.tr("Maturitní četba")) + ListRowLabel(title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub")) } icon: { Image(systemName: "books.vertical") } @@ -3100,27 +3205,38 @@ struct CodeLessonScreen: View { @State private var code = "" @State private var stdin = "" @State private var output = "" - @State private var running = false + @State private var status = "" + + private var running: Bool { status == "running" } + private var needsInput: Bool { code.contains("ReadLine") } var body: some View { GeometryReader { geo in - if geo.size.width >= 840 { - HStack(alignment: .top, spacing: 16) { - explanation.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - editor.frame(maxWidth: .infinity, maxHeight: .infinity) - outputPane.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + if geo.size.width >= 900 { + HStack(alignment: .top, spacing: 28) { + ScrollView { + explanation + .padding(.vertical, 4) + } + .frame(width: min(560, geo.size.width * 0.4)) + workspace(height: geo.size.height) } + .padding(.horizontal, 28) + .padding(.vertical, 20) + .frame(maxWidth: 1640) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 20) { explanation - editor.frame(minHeight: 240) - outputPane.frame(minHeight: 160) + editorCard.frame(height: 320) + if needsInput { inputField } + outputCard.frame(height: 220) } + .padding(16) } } } - .padding(16) .detailScreen(vm.tr(lesson.str("titleKey")), subtitle: vm.tr(lesson.str("subKey"))) .onAppear { if code.isEmpty { code = lesson.str("code") } @@ -3128,84 +3244,154 @@ struct CodeLessonScreen: View { } private var explanation: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - ForEach(Array(lesson.arr("slides").enumerated()), id: \.offset) { _, slide in - VStack(alignment: .leading, spacing: 8) { - Text(slide.str("kicker")) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Color.accentColor) - Text(slide.str("title")) - .font(.title3.bold()) - ForEach(slide.strs("lines"), id: \.self) { line in - Text("• \(line)") - .frame(maxWidth: .infinity, alignment: .leading) - } - if let tip = slide.strOrNull("tip") { - Text(tip) - .foregroundStyle(.secondary) - } - } - } + VStack(alignment: .leading, spacing: 34) { + ForEach(Array(lesson.arr("slides").enumerated()), id: \.offset) { _, slide in + SlideBody(slide: slide) } } } - private var editor: some View { - VStack(alignment: .leading, spacing: 8) { - Text(vm.tr("cs_code")).font(.headline) - TextEditor(text: $code) - .font(.system(.body, design: .monospaced)) - .scrollContentBackground(.hidden) - .padding(8) + private func workspace(height: CGFloat) -> some View { + VStack(spacing: 16) { + editorCard + .frame(maxHeight: .infinity) + if needsInput { inputField } + outputCard + .frame(height: max(180, height * 0.32)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var editorCard: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .foregroundStyle(Color.accentColor) + Text("Program.cs") + .font(.headline.monospaced()) + Spacer(minLength: 8) + Button { + code = lesson.str("code") + output = "" + status = "" + } label: { + Label(vm.tr("cs_reset"), systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .disabled(running) + Button { run() } label: { + Label(vm.tr("cs_run"), systemImage: "play.fill") + .padding(.horizontal, 4) + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: .command) + .help("⌘↩") + .disabled(running) + } + #if os(macOS) + .controlSize(.large) + #endif + .padding(.horizontal, 14) + .padding(.vertical, 10) + Divider().opacity(0.5) + editorField .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(codeFill, in: RoundedRectangle(cornerRadius: 12)) - Text(vm.tr("cs_stdin")) - .font(.caption) + } + .background(codeFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.separator.opacity(0.6), lineWidth: 0.5) + } + } + + @ViewBuilder + private var editorField: some View { + #if os(macOS) + CodeEditor(text: $code) + #else + TextEditor(text: $code) + .font(.system(size: 15, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .scrollContentBackground(.hidden) + .padding(8) + #endif + } + + private var inputField: some View { + VStack(alignment: .leading, spacing: 6) { + Label(vm.tr("cs_stdin"), systemImage: "keyboard") + .font(.subheadline) .foregroundStyle(.secondary) TextField("", text: $stdin, axis: .vertical) - .font(.system(.body, design: .monospaced)) - .lineLimit(2...4) + .font(.system(size: 15, design: .monospaced)) + .lineLimit(1...4) .textFieldStyle(.roundedBorder) } } - private var outputPane: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Button(vm.tr("cs_run")) { run() } - .buttonStyle(.borderedProminent) - .disabled(running) - Button(vm.tr("cs_reset")) { code = lesson.str("code") } - .disabled(running) + private var outputCard: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: "terminal") + .foregroundStyle(.secondary) + Text(vm.tr("cs_output")) + .font(.headline) + Spacer(minLength: 8) + statusBadge } - Text(vm.tr("cs_output")).font(.headline) + .padding(.horizontal, 14) + .frame(height: 44) + Divider().opacity(0.5) ScrollView { Text(output.isEmpty ? vm.tr("cs_empty") : output) - .font(.system(.body, design: .monospaced)) + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(output.isEmpty || running ? .secondary : .primary) .frame(maxWidth: .infinity, alignment: .topLeading) .textSelection(.enabled) + .padding(14) } - .padding(12) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(codeFill, in: RoundedRectangle(cornerRadius: 12)) + } + .background(codeFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.separator.opacity(0.6), lineWidth: 0.5) } } - private var codeFill: Color { + @ViewBuilder + private var statusBadge: some View { + switch status { + case "running": + ProgressView().controlSize(.small) + case "ok": + Label(vm.tr("cs_done"), systemImage: "checkmark.circle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(vm.palette.success) + case "fail", "timeout": + Label(vm.tr("cs_failed"), systemImage: "xmark.octagon.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(vm.palette.error) + default: + EmptyView() + } + } + + private var codeFill: AnyShapeStyle { #if os(macOS) - Color(.textBackgroundColor).opacity(0.55) + AnyShapeStyle(.background.secondary) #else - Color(.secondarySystemBackground) + AnyShapeStyle(Color(.secondarySystemBackground)) #endif } private func run() { - running = true + guard !running else { return } + status = "running" output = vm.tr("cs_running") - CSharpRunner.run(code, stdin: stdin) { status, text in - running = false - switch status { + CSharpRunner.run(code, stdin: needsInput ? stdin : "") { result, text in + status = result + switch result { case "unavailable": output = vm.tr("cs_unavailable") case "timeout": @@ -3213,11 +3399,125 @@ struct CodeLessonScreen: View { default: output = text.isEmpty ? vm.tr("cs_empty") : text } - if status == "ok" { onSuccess() } + if result == "ok" { onSuccess() } } } } +#if os(macOS) +/// Plain-text editor for C#: monospaced, coloured, and without smart quotes or +/// autocorrect, which would turn "text" into “text” and break the program. +struct CodeEditor: NSViewRepresentable { + @Binding var text: String + + func makeCoordinator() -> Coordinator { Coordinator(text: $text) } + + func makeNSView(context: Context) -> NSScrollView { + let scroll = NSTextView.scrollableTextView() + scroll.drawsBackground = false + scroll.autohidesScrollers = true + guard let view = scroll.documentView as? NSTextView else { return scroll } + view.delegate = context.coordinator + view.isRichText = false + view.allowsUndo = true + view.isAutomaticQuoteSubstitutionEnabled = false + view.isAutomaticDashSubstitutionEnabled = false + view.isAutomaticTextReplacementEnabled = false + view.isAutomaticSpellingCorrectionEnabled = false + view.isContinuousSpellCheckingEnabled = false + view.isGrammarCheckingEnabled = false + view.isAutomaticLinkDetectionEnabled = false + view.smartInsertDeleteEnabled = false + view.drawsBackground = false + view.font = CSharpStyle.font + view.textContainerInset = NSSize(width: 10, height: 12) + view.typingAttributes = CSharpStyle.base + view.string = text + CSharpStyle.highlight(view.textStorage) + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + context.coordinator.text = $text + guard let view = scroll.documentView as? NSTextView, view.string != text else { return } + view.string = text + CSharpStyle.highlight(view.textStorage) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var text: Binding + + init(text: Binding) { + self.text = text + } + + func textDidChange(_ notification: Notification) { + guard let view = notification.object as? NSTextView else { return } + text.wrappedValue = view.string + CSharpStyle.highlight(view.textStorage) + view.typingAttributes = CSharpStyle.base + } + } +} + +enum CSharpStyle { + static let font = NSFont.monospacedSystemFont(ofSize: 15, weight: .regular) + static var base: [NSAttributedString.Key: Any] { [.font: font, .foregroundColor: NSColor.labelColor] } + + private static func adaptive(_ light: Int, _ dark: Int) -> NSColor { + func color(_ hex: Int) -> NSColor { + NSColor( + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1 + ) + } + return NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? color(dark) : color(light) + } + } + + private static let keywords = [ + "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", + ] + + private static let rules: [(NSRegularExpression, NSColor)] = { + func re(_ pattern: String) -> NSRegularExpression { + try! NSRegularExpression(pattern: pattern) + } + return [ + (re("\\b(?:" + keywords.joined(separator: "|") + ")\\b"), adaptive(0x9B2393, 0xFF7AB2)), + (re("\\b(?:Console|Math|List|Dictionary|File|Task|Exception|Func|Action|String|DateTime|Random)\\b"), adaptive(0x0B4F79, 0x5DD8FF)), + (re("\\b\\d+(?:\\.\\d+)?\\b"), adaptive(0x1C00CF, 0xD9C97C)), + (re("\\$?@?\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])'"), adaptive(0xC41A16, 0xFC6A5D)), + (re("//[^\\n]*|/\\*[\\s\\S]*?\\*/"), adaptive(0x5D6C79, 0x7F8C98)), + ] + }() + + static func highlight(_ storage: NSTextStorage?) { + guard let storage else { return } + let text = storage.string + let full = NSRange(location: 0, length: (text as NSString).length) + storage.beginEditing() + storage.setAttributes(base, range: full) + for (pattern, color) in rules { + pattern.enumerateMatches(in: text, range: full) { match, _, _ in + if let range = match?.range { + storage.addAttribute(.foregroundColor, value: color, range: range) + } + } + } + storage.endEditing() + } +} +#endif + enum CSharpRunner { static func run(_ source: String, stdin: String, done: @escaping (String, String) -> Void) { #if os(macOS)