mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 07:04:35 +00:00
Polish the UI on all platforms with progress and a two-column code editor.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
333085ee53
commit
0047bd74f0
9 changed files with 1288 additions and 594 deletions
|
|
@ -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()) }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,59 @@
|
|||
package org.maturita.maturita.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
import androidx.compose.ui.unit.min
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.maturita.maturita.AppViewModel
|
||||
import org.maturita.maturita.data.J
|
||||
|
|
@ -34,18 +61,21 @@ import org.maturita.maturita.data.J
|
|||
/** Explanation on one side, the program and its output on the other. */
|
||||
@Composable
|
||||
fun CodeLessonScreen(vm: AppViewModel, lesson: J, onSuccess: () -> Unit) {
|
||||
val key = lesson.str("key")
|
||||
val starter = lesson.str("code")
|
||||
var code by remember(lesson.str("key")) { mutableStateOf(starter) }
|
||||
var stdin by remember(lesson.str("key")) { mutableStateOf("") }
|
||||
var output by remember(lesson.str("key")) { mutableStateOf("") }
|
||||
var running by remember(lesson.str("key")) { mutableStateOf(false) }
|
||||
var code by remember(key) { mutableStateOf(starter) }
|
||||
var stdin by remember(key) { mutableStateOf("") }
|
||||
var output by remember(key) { mutableStateOf("") }
|
||||
var status by remember(key) { mutableStateOf("") }
|
||||
val running = status == "running"
|
||||
val needsInput = "ReadLine" in code
|
||||
|
||||
fun run() {
|
||||
if (running) return
|
||||
running = true
|
||||
status = "running"
|
||||
output = vm.tr("cs_running")
|
||||
vm.platform.runCSharp(code, stdin) { result ->
|
||||
running = false
|
||||
vm.platform.runCSharp(code, if (needsInput) stdin else "") { result ->
|
||||
status = result.status
|
||||
output = when (result.status) {
|
||||
"unavailable" -> vm.tr("cs_unavailable")
|
||||
"timeout" -> (result.output + "\n\n" + vm.tr("cs_timeout")).trim()
|
||||
|
|
@ -55,19 +85,39 @@ fun CodeLessonScreen(vm: AppViewModel, lesson: J, onSuccess: () -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
DetailScaffold(vm, vm.tr(lesson.str("titleKey")), vm.tr(lesson.str("subKey"))) { inner ->
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(inner).padding(12.dp)) {
|
||||
if (maxWidth >= 840.dp) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Explanation(vm, lesson, Modifier.weight(0.9f).fillMaxHeight())
|
||||
EditorColumn(vm, code, { code = it }, stdin, { stdin = it }, expand = true, Modifier.weight(1.15f).fillMaxHeight())
|
||||
OutputColumn(vm, output, running, { run() }, { code = starter }, expand = true, Modifier.weight(1f).fillMaxHeight())
|
||||
fun reset() {
|
||||
code = starter
|
||||
output = ""
|
||||
status = ""
|
||||
}
|
||||
|
||||
DetailScaffold(vm, vm.tr(lesson.str("titleKey")), vm.tr(lesson.str("subKey")), maxContentWidth = 1640.dp) { inner ->
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(inner)) {
|
||||
val width = maxWidth
|
||||
val height = maxHeight
|
||||
if (width >= 900.dp) {
|
||||
Row(
|
||||
Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Column(Modifier.width(min(560.dp, width * 0.4f)).fillMaxHeight().verticalScroll(rememberScrollState())) {
|
||||
Explanation(lesson)
|
||||
}
|
||||
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
EditorCard(vm, code, { code = it }, running, ::run, ::reset, compact = false, Modifier.weight(1f))
|
||||
if (needsInput) InputField(vm, stdin) { stdin = it }
|
||||
OutputCard(vm, output, status, Modifier.height(max(180.dp, height * 0.32f)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Explanation(vm, lesson, Modifier.fillMaxWidth())
|
||||
EditorColumn(vm, code, { code = it }, stdin, { stdin = it }, expand = false, Modifier.fillMaxWidth())
|
||||
OutputColumn(vm, output, running, { run() }, { code = starter }, expand = false, Modifier.fillMaxWidth())
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Explanation(lesson)
|
||||
EditorCard(vm, code, { code = it }, running, ::run, ::reset, compact = true, Modifier.height(320.dp))
|
||||
if (needsInput) InputField(vm, stdin) { stdin = it }
|
||||
OutputCard(vm, output, status, Modifier.height(220.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -75,84 +125,179 @@ fun CodeLessonScreen(vm: AppViewModel, lesson: J, onSuccess: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun Explanation(vm: AppViewModel, lesson: J, modifier: Modifier) {
|
||||
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
lesson.arr("slides").forEach { slide ->
|
||||
Text(slide.str("kicker"), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge)
|
||||
Text(slide.str("title"), style = MaterialTheme.typography.titleLarge)
|
||||
slide.strs("lines").forEach { line ->
|
||||
Text("• $line", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
slide.strOrNull("tip")?.let { tip ->
|
||||
Text(tip, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
private fun Explanation(lesson: J) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(28.dp)) {
|
||||
lesson.arr("slides").forEach { SlideContent(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorColumn(
|
||||
private fun EditorCard(
|
||||
vm: AppViewModel,
|
||||
code: String,
|
||||
onCode: (String) -> Unit,
|
||||
stdin: String,
|
||||
onStdin: (String) -> Unit,
|
||||
expand: Boolean,
|
||||
running: Boolean,
|
||||
onRun: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
compact: Boolean,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(vm.tr("cs_code"), style = MaterialTheme.typography.titleMedium)
|
||||
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||
val highlight = remember(dark) { CSharpHighlight(dark) }
|
||||
Surface(modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||
Column {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Code, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text("Program.cs", style = MaterialTheme.typography.titleSmall.copy(fontFamily = FontFamily.Monospace))
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (compact) {
|
||||
IconButton(onClick = onReset, enabled = !running) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = vm.tr("cs_reset"))
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(onClick = onReset, enabled = !running) {
|
||||
Icon(Icons.Filled.Refresh, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(vm.tr("cs_reset"))
|
||||
}
|
||||
}
|
||||
Button(onClick = onRun, enabled = !running) {
|
||||
Icon(Icons.Filled.PlayArrow, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(vm.tr("cs_run"))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
BasicTextField(
|
||||
value = code,
|
||||
onValueChange = onCode,
|
||||
textStyle = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier
|
||||
.then(if (expand) Modifier.weight(1f) else Modifier.height(240.dp))
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(12.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
visualTransformation = highlight,
|
||||
modifier = Modifier.fillMaxSize().padding(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputField(vm: AppViewModel, stdin: String, onStdin: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(vm.tr("cs_stdin"), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
BasicTextField(
|
||||
OutlinedTextField(
|
||||
value = stdin,
|
||||
onValueChange = onStdin,
|
||||
textStyle = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainer).padding(12.dp),
|
||||
textStyle = codeStyle(),
|
||||
minLines = 1,
|
||||
maxLines = 4,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OutputColumn(
|
||||
vm: AppViewModel,
|
||||
output: String,
|
||||
running: Boolean,
|
||||
onRun: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
expand: Boolean,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
Text(vm.tr("cs_output"), style = MaterialTheme.typography.titleMedium)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Text(
|
||||
output.ifEmpty { vm.tr("cs_empty") },
|
||||
style = codeStyle(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.then(if (expand) Modifier.weight(1f) else Modifier.height(180.dp))
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(12.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
color = if (muted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun codeStyle() = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp, lineHeight = 20.sp)
|
||||
@Composable
|
||||
private fun StatusBadge(vm: AppViewModel, status: String) {
|
||||
when (status) {
|
||||
"running" -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
"ok" -> Badge(vm.tr("cs_done"), Icons.Filled.CheckCircle, MaterialTheme.colorScheme.primary)
|
||||
"fail", "timeout" -> Badge(vm.tr("cs_failed"), Icons.Filled.Cancel, MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Badge(text: String, icon: androidx.compose.ui.graphics.vector.ImageVector, color: Color) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, tint = color, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.labelLarge, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
private fun codeStyle() = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 15.sp, lineHeight = 22.sp)
|
||||
|
||||
/** Colours C# keywords, types, numbers, strings and comments without changing the text. */
|
||||
private class CSharpHighlight(dark: Boolean) : VisualTransformation {
|
||||
private val palette = listOf(
|
||||
Color(if (dark) 0xFFFF7AB2 else 0xFF9B2393),
|
||||
Color(if (dark) 0xFF5DD8FF else 0xFF0B4F79),
|
||||
Color(if (dark) 0xFFD9C97C else 0xFF1C00CF),
|
||||
Color(if (dark) 0xFFFC6A5D else 0xFFC41A16),
|
||||
Color(if (dark) 0xFF7F8C98 else 0xFF5D6C79),
|
||||
)
|
||||
|
||||
override fun filter(text: AnnotatedString): TransformedText =
|
||||
TransformedText(highlight(text.text), OffsetMapping.Identity)
|
||||
|
||||
private fun highlight(code: String): AnnotatedString {
|
||||
val kind = IntArray(code.length) { -1 }
|
||||
RULES.forEachIndexed { index, rule ->
|
||||
rule.findAll(code).forEach { m -> for (i in m.range) kind[i] = index }
|
||||
}
|
||||
return buildAnnotatedString {
|
||||
var start = 0
|
||||
while (start < code.length) {
|
||||
var end = start
|
||||
while (end < code.length && kind[end] == kind[start]) end++
|
||||
val k = kind[start]
|
||||
if (k >= 0) {
|
||||
pushStyle(SpanStyle(color = palette[k]))
|
||||
append(code, start, end)
|
||||
pop()
|
||||
} else {
|
||||
append(code, start, end)
|
||||
}
|
||||
start = end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEYWORDS = listOf(
|
||||
"abstract", "as", "async", "await", "base", "bool", "break", "case", "catch", "char", "class",
|
||||
"const", "continue", "decimal", "default", "do", "double", "else", "enum", "false", "finally",
|
||||
"float", "for", "foreach", "get", "if", "in", "int", "interface", "internal", "is", "long",
|
||||
"namespace", "new", "null", "object", "out", "override", "private", "protected", "public",
|
||||
"readonly", "record", "ref", "return", "sealed", "set", "static", "string", "struct", "switch",
|
||||
"this", "throw", "true", "try", "using", "var", "virtual", "void", "while",
|
||||
)
|
||||
|
||||
/** Later rules win, so strings and comments override keywords inside them. */
|
||||
private val RULES = listOf(
|
||||
Regex("\\b(?:" + KEYWORDS.joinToString("|") + ")\\b"),
|
||||
Regex("\\b(?:Console|Math|List|Dictionary|File|Task|Exception|Func|Action|String|DateTime|Random)\\b"),
|
||||
Regex("\\b\\d+(?:\\.\\d+)?\\b"),
|
||||
Regex("\\$?@?\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])'"),
|
||||
Regex("//[^\\n]*|/\\*[\\s\\S]*?\\*/"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,15 +8,22 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
|
|
@ -24,7 +31,17 @@ import androidx.compose.foundation.layout.navigationBars
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
|
|
@ -44,7 +61,6 @@ import androidx.compose.material3.FilterChip
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -150,7 +166,19 @@ fun AppNavBar(vm: AppViewModel) {
|
|||
private fun navIconColor(selected: Boolean): Color =
|
||||
if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
/** Tab root: large collapsing title, search action, bottom navigation. */
|
||||
/** Lists and exercises stop growing here so they keep their shape in wide desktop windows. */
|
||||
val ContentMaxWidth = 880.dp
|
||||
val ActionMaxWidth = 560.dp
|
||||
|
||||
/** Caps the width of [content] and centres it; phones are narrower than the cap anyway. */
|
||||
@Composable
|
||||
fun CenteredColumn(maxWidth: Dp = ContentMaxWidth, content: @Composable () -> Unit) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
Box(Modifier.widthIn(max = maxWidth).fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Tab root: large collapsing title on phones, a compact one in wide windows. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RootScaffold(
|
||||
|
|
@ -159,24 +187,40 @@ fun RootScaffold(
|
|||
showSearch: Boolean = true,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
actions = {
|
||||
BoxWithConstraints {
|
||||
val wide = maxWidth >= 600.dp
|
||||
val scroll = if (wide) {
|
||||
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
} else {
|
||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
}
|
||||
val actions: @Composable RowScope.() -> Unit = {
|
||||
if (showSearch) {
|
||||
IconButton(onClick = { vm.searchOpen = true }) {
|
||||
Icon(Icons.Filled.Search, contentDescription = vm.tr("search_placeholder"))
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
|
||||
topBar = {
|
||||
if (wide) {
|
||||
TopAppBar(
|
||||
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineSmall) },
|
||||
actions = actions,
|
||||
scrollBehavior = scroll,
|
||||
)
|
||||
} else {
|
||||
LargeTopAppBar(
|
||||
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
actions = actions,
|
||||
scrollBehavior = scroll,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = { AppNavBar(vm) },
|
||||
) { inner ->
|
||||
CenteredColumn {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = inner.calculateTopPadding(),
|
||||
|
|
@ -184,6 +228,8 @@ fun RootScaffold(
|
|||
),
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pushed screen: back arrow, title with optional subtitle, optional bottom action bar. */
|
||||
|
|
@ -194,6 +240,7 @@ fun DetailScaffold(
|
|||
title: String,
|
||||
subtitle: String? = null,
|
||||
bottomBar: (@Composable () -> Unit)? = null,
|
||||
maxContentWidth: Dp = ContentMaxWidth,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
val scroll = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
|
@ -224,8 +271,9 @@ fun DetailScaffold(
|
|||
)
|
||||
},
|
||||
bottomBar = { bottomBar?.invoke() },
|
||||
content = content,
|
||||
)
|
||||
) { inner ->
|
||||
CenteredColumn(maxContentWidth) { content(inner) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Feedback line plus the primary action, anchored at the bottom of an exercise. */
|
||||
|
|
@ -242,6 +290,8 @@ fun ActionBar(
|
|||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.CenterHorizontally)
|
||||
.widthIn(max = ActionMaxWidth)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
|
|
@ -276,6 +326,27 @@ fun SectionHeader(text: String) {
|
|||
)
|
||||
}
|
||||
|
||||
/** Number or short label in a tinted square, so rows line up whatever the label's width. */
|
||||
@Composable
|
||||
fun LeadingBadge(text: String) {
|
||||
Box(
|
||||
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LeadingIcon(icon: ImageVector) {
|
||||
Box(
|
||||
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** One navigable row: done/pending/locked state on the left, chevron on the right. */
|
||||
@Composable
|
||||
fun NavRow(
|
||||
|
|
@ -284,15 +355,33 @@ fun NavRow(
|
|||
done: Boolean = false,
|
||||
locked: Boolean = false,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
progress: Pair<Int, Int>? = null,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
val enabled = !locked && onClick != null
|
||||
val textColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val rowModifier = if (enabled) Modifier.clickable(onClick = onClick!!) else Modifier
|
||||
val shownSubtitle = subtitle?.takeIf { it.isNotBlank() }
|
||||
val shownProgress = progress?.takeIf { it.second > 0 }
|
||||
ListItem(
|
||||
headlineContent = { Text(title, color = textColor) },
|
||||
supportingContent = subtitle?.takeIf { it.isNotBlank() }?.let {
|
||||
{ Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
supportingContent = if (shownSubtitle == null && shownProgress == null) null else {
|
||||
{
|
||||
Column {
|
||||
shownSubtitle?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
shownProgress?.let { (doneCount, total) ->
|
||||
Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
SmoothProgress(doneCount / total.toFloat(), Modifier.weight(1f).height(6.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
"$doneCount / $total",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
when {
|
||||
|
|
@ -328,19 +417,38 @@ fun SmoothProgress(
|
|||
progress: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.primary,
|
||||
trackColor: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
|
||||
) {
|
||||
val animated by animateFloatAsState(
|
||||
targetValue = progress.coerceIn(0f, 1f),
|
||||
animationSpec = tween(durationMillis = 380),
|
||||
label = "progress",
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { animated },
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
trackColor = trackColor,
|
||||
)
|
||||
Box(modifier.heightIn(min = 4.dp).clip(CircleShape).background(trackColor)) {
|
||||
if (animated > 0f) {
|
||||
Box(Modifier.fillMaxHeight().fillMaxWidth(animated).clip(CircleShape).background(color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Circular progress with a label in the middle. */
|
||||
@Composable
|
||||
fun ProgressRing(progress: Float, modifier: Modifier = Modifier, stroke: Dp = 10.dp, label: @Composable () -> Unit) {
|
||||
val animated by animateFloatAsState(progress.coerceIn(0f, 1f), tween(durationMillis = 380), label = "ring")
|
||||
val track = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
||||
val fill = MaterialTheme.colorScheme.primary
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val width = stroke.toPx()
|
||||
val inset = width / 2
|
||||
val arcSize = Size(size.width - width, size.height - width)
|
||||
drawArc(track, 0f, 360f, false, Offset(inset, inset), arcSize, style = Stroke(width))
|
||||
if (animated > 0f) {
|
||||
drawArc(fill, -90f, 360f * animated, false, Offset(inset, inset), arcSize, style = Stroke(width, cap = StrokeCap.Round))
|
||||
}
|
||||
}
|
||||
label()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Architecture
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.AutoStories
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Eco
|
||||
import androidx.compose.material.icons.filled.Functions
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
|
|
@ -101,50 +104,61 @@ fun HomeScreen(vm: AppViewModel) {
|
|||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
vm.tr("welcome_title"),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(vm.tr("welcome_title"), style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
vm.tr(vm.platform.welcomeKey),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SmoothProgress(
|
||||
progress = pct,
|
||||
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.18f),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
ProgressRing(pct, Modifier.size(104.dp)) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(vm.fmt("stats_pct_fmt", (pct * 100).toInt()), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") {
|
||||
item { UpdateBanner(vm) }
|
||||
}
|
||||
val open = vm.content.subjects.filter { it.bool("open") }
|
||||
val soon = vm.content.subjects.filter { !it.bool("open") }
|
||||
item { SectionHeader(vm.tr("subjects_title")) }
|
||||
items(vm.content.subjects) { s ->
|
||||
val open = s.bool("open")
|
||||
items(open) { s ->
|
||||
val part = subjectSum(vm, s)
|
||||
NavRow(
|
||||
title = vm.tr(s.str("key")),
|
||||
subtitle = if (open) null else vm.tr("stats_locked"),
|
||||
locked = !open,
|
||||
leading = { SubjectIcon(s.str("icon"), open) },
|
||||
onClick = if (open) ({ vm.goPage(s.str("target")) }) else null,
|
||||
leading = { SubjectIcon(s.str("icon"), true) },
|
||||
progress = part.doneEx to part.totalEx,
|
||||
onClick = { vm.goPage(s.str("target")) },
|
||||
)
|
||||
}
|
||||
if (soon.isNotEmpty()) {
|
||||
item { SectionHeader(vm.tr("subjects_soon")) }
|
||||
items(soon) { s ->
|
||||
NavRow(
|
||||
title = vm.tr(s.str("key")),
|
||||
locked = true,
|
||||
leading = { SubjectIcon(s.str("icon"), false) },
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,14 +168,18 @@ private fun ChangelogCard(vm: AppViewModel) {
|
|||
val blocks = vm.content.changelog.take(3)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.AutoAwesome, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
vm.tr("changelog_title"),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
val date = blocks.firstOrNull()?.str("date").orEmpty()
|
||||
|
|
@ -169,7 +187,7 @@ private fun ChangelogCard(vm: AppViewModel) {
|
|||
Text(
|
||||
date,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.7f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -180,7 +198,6 @@ private fun ChangelogCard(vm: AppViewModel) {
|
|||
Text(
|
||||
"• $line",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
|
@ -293,6 +310,67 @@ private fun Metric(label: String, value: String, modifier: Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Done and total exercises behind one row of a years screen, or null when there is no count. */
|
||||
fun routeProgress(vm: AppViewModel, r: Route): Pair<Int, Int>? {
|
||||
val c = vm.content
|
||||
val p = vm.progress
|
||||
fun tally(lessons: List<J>, done: (Int) -> Boolean) = lessons.count { done(it.int("id")) } to lessons.size
|
||||
return when (r) {
|
||||
Route.NetMap -> tally(c.netLessons, p::netDone)
|
||||
Route.Net2Map -> tally(c.itLessons("net2")) { p.courseDone("net2", it) }
|
||||
Route.Net3Map -> tally(c.itLessons("net3")) { p.courseDone("net3", it) }
|
||||
Route.Net4Map -> tally(c.itLessons("net4")) { p.courseDone("net4", it) }
|
||||
Route.HwMap -> tally(c.hw, p::hwDone)
|
||||
Route.Hw2Map -> tally(c.itLessons("hw2")) { p.courseDone("hw2", it) }
|
||||
Route.Hw3Map -> tally(c.itLessons("hw3")) { p.courseDone("hw3", it) }
|
||||
Route.Hw4Map -> tally(c.itLessons("hw4")) { p.courseDone("hw4", it) }
|
||||
Route.OnMap -> tally(c.on, p::onDone)
|
||||
Route.On2Map -> tally(c.on2, p::on2Done)
|
||||
Route.On3Map -> tally(c.on3, p::on3Done)
|
||||
Route.On4Map -> tally(c.on4, p::on4Done)
|
||||
is Route.EnMap -> tally(c.enYear(r.year)) { p.enDone(r.year, it) }
|
||||
is Route.DeMap -> tally(c.deYear(r.year)) { p.deDone(r.year, it) }
|
||||
Route.Roadmap -> {
|
||||
var done = 0
|
||||
var total = 0
|
||||
c.german.filter { it.bool("unlocked") }.forEach { u ->
|
||||
val n = u.strs("names").size
|
||||
total += n
|
||||
done += (1..n).count { p.germanDone(u.int("id"), it) }
|
||||
}
|
||||
done to total
|
||||
}
|
||||
Route.LitYears -> listOf(
|
||||
tally(c.lit, p::litDone), tally(c.lit2, p::lit2Done), tally(c.lit3, p::lit3Done), tally(c.lit4, p::lit4Done),
|
||||
).let { parts -> parts.sumOf { it.first } to parts.sumOf { it.second } }
|
||||
Route.LitMap -> tally(c.lit, p::litDone)
|
||||
Route.Lit2Map -> tally(c.lit2, p::lit2Done)
|
||||
Route.Lit3Map -> tally(c.lit3, p::lit3Done)
|
||||
Route.Lit4Map -> tally(c.lit4, p::lit4Done)
|
||||
Route.Mluvnice -> tally(c.mluvnice, p::mluvDone)
|
||||
Route.ChemMap -> tally(c.chem, p::chemDone)
|
||||
Route.BioMap -> tally(c.bio, p::bioDone)
|
||||
Route.Mat0Map -> tally(c.mat0, p::mat0Done)
|
||||
Route.MatMap -> tally(c.mat, p::matDone)
|
||||
Route.Mat2Map -> tally(c.mat2, p::mat2Done)
|
||||
Route.Mat3Map -> tally(c.mat3, p::mat3Done)
|
||||
Route.Mat4Map -> tally(c.mat4, p::mat4Done)
|
||||
Route.FyzMap -> tally(c.fyz, p::fyzDone)
|
||||
Route.Fyz2Map -> tally(c.fyz2, p::fyz2Done)
|
||||
Route.Fyz3Map -> tally(c.fyz3, p::fyz3Done)
|
||||
Route.Fyz4Map -> tally(c.fyz4, p::fyz4Done)
|
||||
Route.TgMap -> tally(c.tg, p::tgDone)
|
||||
Route.Tg2Map -> tally(c.tg2, p::tg2Done)
|
||||
Route.Tg3Map -> tally(c.tg3, p::tg3Done)
|
||||
Route.Tg4Map -> tally(c.tg4, p::tg4Done)
|
||||
Route.CsMap -> tally(c.cs, p::csDone)
|
||||
Route.Cs2Map -> tally(c.cs2, p::cs2Done)
|
||||
Route.Cs3Map -> tally(c.cs3, p::cs3Done)
|
||||
Route.Cs4Map -> tally(c.cs4, p::cs4Done)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun subjectSum(vm: AppViewModel, s: J): ProgressSum {
|
||||
val sum = ProgressSum()
|
||||
when (s.str("target")) {
|
||||
|
|
@ -801,10 +879,10 @@ fun NetYearsScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("net_years_title"), vm.tr("net_years_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("net_year1"), subtitle = vm.tr("net_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.NetMap) }
|
||||
NavRow(title = vm.tr("net_year2"), subtitle = vm.tr("net2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Net2Map) }
|
||||
NavRow(title = vm.tr("net_year3"), subtitle = vm.tr("net3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Net3Map) }
|
||||
NavRow(title = vm.tr("net_year4"), subtitle = vm.tr("net4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Net4Map) }
|
||||
NavRow(title = vm.tr("net_year1"), subtitle = vm.tr("net_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.NetMap)) { vm.go(Route.NetMap) }
|
||||
NavRow(title = vm.tr("net_year2"), subtitle = vm.tr("net2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Net2Map)) { vm.go(Route.Net2Map) }
|
||||
NavRow(title = vm.tr("net_year3"), subtitle = vm.tr("net3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Net3Map)) { vm.go(Route.Net3Map) }
|
||||
NavRow(title = vm.tr("net_year4"), subtitle = vm.tr("net4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Net4Map)) { vm.go(Route.Net4Map) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -815,10 +893,10 @@ fun HwYearsScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("Technické vybavení"), vm.tr("hw_years_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("hw_year1"), subtitle = vm.tr("hw_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.HwMap) }
|
||||
NavRow(title = vm.tr("hw_year2"), subtitle = vm.tr("hw2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Hw2Map) }
|
||||
NavRow(title = vm.tr("hw_year3"), subtitle = vm.tr("hw3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Hw3Map) }
|
||||
NavRow(title = vm.tr("hw_year4"), subtitle = vm.tr("hw4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Hw4Map) }
|
||||
NavRow(title = vm.tr("hw_year1"), subtitle = vm.tr("hw_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.HwMap)) { vm.go(Route.HwMap) }
|
||||
NavRow(title = vm.tr("hw_year2"), subtitle = vm.tr("hw2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Hw2Map)) { vm.go(Route.Hw2Map) }
|
||||
NavRow(title = vm.tr("hw_year3"), subtitle = vm.tr("hw3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Hw3Map)) { vm.go(Route.Hw3Map) }
|
||||
NavRow(title = vm.tr("hw_year4"), subtitle = vm.tr("hw4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Hw4Map)) { vm.go(Route.Hw4Map) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -829,7 +907,7 @@ fun DeHomeScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("Deutsch"), vm.tr("de_home_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("de_book_title"), subtitle = vm.tr("de_book_sub"), leading = { Text("U", style = MaterialTheme.typography.titleMedium) }) {
|
||||
NavRow(title = vm.tr("de_book_title"), subtitle = vm.tr("de_book_sub"), leading = { LeadingBadge("U") }, progress = routeProgress(vm, Route.Roadmap)) {
|
||||
vm.go(Route.Roadmap)
|
||||
}
|
||||
}
|
||||
|
|
@ -838,7 +916,8 @@ fun DeHomeScreen(vm: AppViewModel) {
|
|||
NavRow(
|
||||
title = vm.tr("de_year$year"),
|
||||
subtitle = vm.tr("de_y${year}_sub"),
|
||||
leading = { Text("$year.", style = MaterialTheme.typography.titleMedium) },
|
||||
leading = { LeadingBadge("$year") },
|
||||
progress = routeProgress(vm, Route.DeMap(year)),
|
||||
) { vm.go(Route.DeMap(year)) }
|
||||
}
|
||||
}
|
||||
|
|
@ -854,7 +933,8 @@ fun EnYearsScreen(vm: AppViewModel) {
|
|||
NavRow(
|
||||
title = vm.tr("en_year$year"),
|
||||
subtitle = vm.tr("en_y${year}_sub"),
|
||||
leading = { Text("$year.", style = MaterialTheme.typography.titleMedium) },
|
||||
leading = { LeadingBadge("$year") },
|
||||
progress = routeProgress(vm, Route.EnMap(year)),
|
||||
) { vm.go(Route.EnMap(year)) }
|
||||
}
|
||||
}
|
||||
|
|
@ -866,10 +946,10 @@ fun OnYearsScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("Občanská nauka"), vm.tr("on_years_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("on_year1"), subtitle = vm.tr("on_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.OnMap) }
|
||||
NavRow(title = vm.tr("on_year2"), subtitle = vm.tr("on2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.On2Map) }
|
||||
NavRow(title = vm.tr("on_year3"), subtitle = vm.tr("on3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.On3Map) }
|
||||
NavRow(title = vm.tr("on_year4"), subtitle = vm.tr("on4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.On4Map) }
|
||||
NavRow(title = vm.tr("on_year1"), subtitle = vm.tr("on_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.OnMap)) { vm.go(Route.OnMap) }
|
||||
NavRow(title = vm.tr("on_year2"), subtitle = vm.tr("on2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.On2Map)) { vm.go(Route.On2Map) }
|
||||
NavRow(title = vm.tr("on_year3"), subtitle = vm.tr("on3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.On3Map)) { vm.go(Route.On3Map) }
|
||||
NavRow(title = vm.tr("on_year4"), subtitle = vm.tr("on4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.On4Map)) { vm.go(Route.On4Map) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -880,10 +960,10 @@ fun LitYearsScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("Literatura"), vm.tr("lit_years_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("lit_year1"), subtitle = vm.tr("lit_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.LitMap) }
|
||||
NavRow(title = vm.tr("lit_year2"), subtitle = vm.tr("lit2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Lit2Map) }
|
||||
NavRow(title = vm.tr("lit_year3"), subtitle = vm.tr("lit3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Lit3Map) }
|
||||
NavRow(title = vm.tr("lit_year4"), subtitle = vm.tr("lit4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Lit4Map) }
|
||||
NavRow(title = vm.tr("lit_year1"), subtitle = vm.tr("lit_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.LitMap)) { vm.go(Route.LitMap) }
|
||||
NavRow(title = vm.tr("lit_year2"), subtitle = vm.tr("lit2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Lit2Map)) { vm.go(Route.Lit2Map) }
|
||||
NavRow(title = vm.tr("lit_year3"), subtitle = vm.tr("lit3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Lit3Map)) { vm.go(Route.Lit3Map) }
|
||||
NavRow(title = vm.tr("lit_year4"), subtitle = vm.tr("lit4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Lit4Map)) { vm.go(Route.Lit4Map) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -894,11 +974,11 @@ fun MatYearsScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("Matematika"), vm.tr("mat_years_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("mat0_year"), subtitle = vm.tr("mat0_sub"), leading = { Text("ZŠ", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Mat0Map) }
|
||||
NavRow(title = vm.tr("mat_year1"), subtitle = vm.tr("mat_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.MatMap) }
|
||||
NavRow(title = vm.tr("mat_year2"), subtitle = vm.tr("mat2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Mat2Map) }
|
||||
NavRow(title = vm.tr("mat_year3"), subtitle = vm.tr("mat3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Mat3Map) }
|
||||
NavRow(title = vm.tr("mat_year4"), subtitle = vm.tr("mat4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Mat4Map) }
|
||||
NavRow(title = vm.tr("mat0_year"), subtitle = vm.tr("mat0_sub"), leading = { LeadingBadge("ZŠ") }, progress = routeProgress(vm, Route.Mat0Map)) { vm.go(Route.Mat0Map) }
|
||||
NavRow(title = vm.tr("mat_year1"), subtitle = vm.tr("mat_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.MatMap)) { vm.go(Route.MatMap) }
|
||||
NavRow(title = vm.tr("mat_year2"), subtitle = vm.tr("mat2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Mat2Map)) { vm.go(Route.Mat2Map) }
|
||||
NavRow(title = vm.tr("mat_year3"), subtitle = vm.tr("mat3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Mat3Map)) { vm.go(Route.Mat3Map) }
|
||||
NavRow(title = vm.tr("mat_year4"), subtitle = vm.tr("mat4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Mat4Map)) { vm.go(Route.Mat4Map) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -909,10 +989,10 @@ fun CsYearsScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("Programování"), vm.tr("cs_years_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("cs_year1"), subtitle = vm.tr("cs_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.CsMap) }
|
||||
NavRow(title = vm.tr("cs_year2"), subtitle = vm.tr("cs2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Cs2Map) }
|
||||
NavRow(title = vm.tr("cs_year3"), subtitle = vm.tr("cs3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Cs3Map) }
|
||||
NavRow(title = vm.tr("cs_year4"), subtitle = vm.tr("cs4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Cs4Map) }
|
||||
NavRow(title = vm.tr("cs_year1"), subtitle = vm.tr("cs_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.CsMap)) { vm.go(Route.CsMap) }
|
||||
NavRow(title = vm.tr("cs_year2"), subtitle = vm.tr("cs2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Cs2Map)) { vm.go(Route.Cs2Map) }
|
||||
NavRow(title = vm.tr("cs_year3"), subtitle = vm.tr("cs3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Cs3Map)) { vm.go(Route.Cs3Map) }
|
||||
NavRow(title = vm.tr("cs_year4"), subtitle = vm.tr("cs4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Cs4Map)) { vm.go(Route.Cs4Map) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -923,10 +1003,10 @@ fun TgYearsScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("Technická grafika"), vm.tr("tg_years_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("tg_year1"), subtitle = vm.tr("tg_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.TgMap) }
|
||||
NavRow(title = vm.tr("tg_year2"), subtitle = vm.tr("tg2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Tg2Map) }
|
||||
NavRow(title = vm.tr("tg_year3"), subtitle = vm.tr("tg3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Tg3Map) }
|
||||
NavRow(title = vm.tr("tg_year4"), subtitle = vm.tr("tg4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Tg4Map) }
|
||||
NavRow(title = vm.tr("tg_year1"), subtitle = vm.tr("tg_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.TgMap)) { vm.go(Route.TgMap) }
|
||||
NavRow(title = vm.tr("tg_year2"), subtitle = vm.tr("tg2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Tg2Map)) { vm.go(Route.Tg2Map) }
|
||||
NavRow(title = vm.tr("tg_year3"), subtitle = vm.tr("tg3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Tg3Map)) { vm.go(Route.Tg3Map) }
|
||||
NavRow(title = vm.tr("tg_year4"), subtitle = vm.tr("tg4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Tg4Map)) { vm.go(Route.Tg4Map) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -937,10 +1017,10 @@ fun FyzYearsScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("Fyzika"), vm.tr("fyz_years_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("fyz_year1"), subtitle = vm.tr("fyz_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.FyzMap) }
|
||||
NavRow(title = vm.tr("fyz_year2"), subtitle = vm.tr("fyz2_sub"), leading = { Text("2.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Fyz2Map) }
|
||||
NavRow(title = vm.tr("fyz_year3"), subtitle = vm.tr("fyz3_sub"), leading = { Text("3.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Fyz3Map) }
|
||||
NavRow(title = vm.tr("fyz_year4"), subtitle = vm.tr("fyz4_sub"), leading = { Text("4.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.Fyz4Map) }
|
||||
NavRow(title = vm.tr("fyz_year1"), subtitle = vm.tr("fyz_sub"), leading = { LeadingBadge("1") }, progress = routeProgress(vm, Route.FyzMap)) { vm.go(Route.FyzMap) }
|
||||
NavRow(title = vm.tr("fyz_year2"), subtitle = vm.tr("fyz2_sub"), leading = { LeadingBadge("2") }, progress = routeProgress(vm, Route.Fyz2Map)) { vm.go(Route.Fyz2Map) }
|
||||
NavRow(title = vm.tr("fyz_year3"), subtitle = vm.tr("fyz3_sub"), leading = { LeadingBadge("3") }, progress = routeProgress(vm, Route.Fyz3Map)) { vm.go(Route.Fyz3Map) }
|
||||
NavRow(title = vm.tr("fyz_year4"), subtitle = vm.tr("fyz4_sub"), leading = { LeadingBadge("4") }, progress = routeProgress(vm, Route.Fyz4Map)) { vm.go(Route.Fyz4Map) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -954,11 +1034,17 @@ fun SciMapScreen(vm: AppViewModel) {
|
|||
NavRow(
|
||||
title = vm.tr("Chemie"),
|
||||
subtitle = vm.tr("chem_sub"),
|
||||
leading = { Icon(Icons.Filled.Science, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
leading = { LeadingIcon(Icons.Filled.Science) },
|
||||
progress = routeProgress(vm, Route.ChemMap),
|
||||
) { vm.go(Route.ChemMap) }
|
||||
}
|
||||
item {
|
||||
NavRow(title = vm.tr("Biologie"), subtitle = vm.tr("bio_sub")) { vm.go(Route.BioMap) }
|
||||
NavRow(
|
||||
title = vm.tr("Biologie"),
|
||||
subtitle = vm.tr("bio_sub"),
|
||||
leading = { LeadingIcon(Icons.Filled.Eco) },
|
||||
progress = routeProgress(vm, Route.BioMap),
|
||||
) { vm.go(Route.BioMap) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -969,20 +1055,26 @@ fun CzechMapScreen(vm: AppViewModel) {
|
|||
DetailScaffold(vm, vm.tr("Český jazyk a literatura"), vm.tr("czech_sub")) { inner ->
|
||||
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {
|
||||
item {
|
||||
NavRow(title = vm.tr("Literatura"), subtitle = vm.tr("lit_years_sub")) { vm.go(Route.LitYears) }
|
||||
NavRow(
|
||||
title = vm.tr("Literatura"),
|
||||
subtitle = vm.tr("lit_years_sub"),
|
||||
leading = { LeadingIcon(Icons.Filled.AutoStories) },
|
||||
progress = routeProgress(vm, Route.LitYears),
|
||||
) { vm.go(Route.LitYears) }
|
||||
}
|
||||
item {
|
||||
NavRow(
|
||||
title = vm.tr("Mluvnice"),
|
||||
subtitle = vm.tr("mluv_sub"),
|
||||
leading = { Icon(Icons.Filled.FormatListNumbered, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
leading = { LeadingIcon(Icons.Filled.FormatListNumbered) },
|
||||
progress = routeProgress(vm, Route.Mluvnice),
|
||||
) { vm.go(Route.Mluvnice) }
|
||||
}
|
||||
item {
|
||||
NavRow(
|
||||
title = vm.tr("Maturitní četba"),
|
||||
subtitle = vm.tr("reading_sub"),
|
||||
leading = { Icon(Icons.AutoMirrored.Filled.MenuBook, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
leading = { LeadingIcon(Icons.AutoMirrored.Filled.MenuBook) },
|
||||
) { vm.go(Route.ReadingList) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1045,6 +1137,52 @@ fun BookScreen(vm: AppViewModel, id: String) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Lesson slides
|
||||
|
||||
/** One explanation slide: label, title, tip and numbered points. */
|
||||
@Composable
|
||||
fun SlideContent(slide: J) {
|
||||
Column {
|
||||
Text(slide.str("kicker"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(slide.str("title"), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 4.dp, bottom = 12.dp))
|
||||
slide.strOrNull("tip")?.let { tip ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Outlined.Lightbulb, null, tint = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(tip, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
slide.strs("lines").forEachIndexed { index, line ->
|
||||
val numbered = Regex("""^(\d+)\.\s+(.*)$""").find(line)
|
||||
val badge = numbered?.groupValues?.get(1) ?: "${index + 1}"
|
||||
val text = numbered?.groupValues?.get(2) ?: line
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), androidx.compose.foundation.shape.RoundedCornerShape(16.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(28.dp)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(badge, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SlidesScreen(
|
||||
|
|
@ -1095,52 +1233,13 @@ fun SlidesScreen(
|
|||
},
|
||||
) { inner ->
|
||||
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize().padding(inner)) { page ->
|
||||
val slide = slides[page]
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(slide.str("kicker"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(slide.str("title"), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 4.dp, bottom = 12.dp))
|
||||
slide.strOrNull("tip")?.let { tip ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Outlined.Lightbulb, null, tint = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(tip, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
slide.strs("lines").forEachIndexed { index, line ->
|
||||
val numbered = Regex("""^(\d+)\.\s+(.*)$""").find(line)
|
||||
val badge = numbered?.groupValues?.get(1) ?: "${index + 1}"
|
||||
val text = numbered?.groupValues?.get(2) ?: line
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), androidx.compose.foundation.shape.RoundedCornerShape(16.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(28.dp)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(badge, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
SlideContent(slides[page])
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -50,109 +50,47 @@ enum MacChrome {
|
|||
static let pageInset: CGFloat = 28
|
||||
static let cardPadding: CGFloat = 22
|
||||
static let listRowMinHeight: CGFloat = 52
|
||||
static let homeTileMin: CGFloat = 240
|
||||
static let homeTileHeight: CGFloat = 168
|
||||
static let homeTileMin: CGFloat = 260
|
||||
static let homeTileHeight: CGFloat = 172
|
||||
static let pathNode: CGFloat = 84
|
||||
/// Pages stop growing here so cards and text keep their shape on wide displays.
|
||||
static let contentWidth: CGFloat = 1240
|
||||
static let readableWidth: CGFloat = 860
|
||||
static let actionWidth: CGFloat = 520
|
||||
}
|
||||
|
||||
enum MacGrid {
|
||||
/// Picks a column count that fills the width and keeps tiles wider than they are tall.
|
||||
static func columns(width: CGFloat, height: CGFloat, count: Int, minWidth: CGFloat, minHeight: CGFloat, spacing: CGFloat) -> Int {
|
||||
guard count > 0, width > 0 else { return 1 }
|
||||
let maxCols = max(1, min(count, Int((width + spacing) / (minWidth + spacing))))
|
||||
var best = maxCols
|
||||
var bestScore = CGFloat.greatestFiniteMagnitude
|
||||
let offeredH = height > 1 ? height : minHeight * CGFloat(count)
|
||||
for cols in 1...maxCols {
|
||||
let rows = Int(ceil(Double(count) / Double(cols)))
|
||||
let tileW = (width - spacing * CGFloat(cols - 1)) / CGFloat(cols)
|
||||
if tileW + 0.5 < minWidth { continue }
|
||||
let needed = CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0))
|
||||
let tileH = max(minHeight, (offeredH - spacing * CGFloat(max(rows - 1, 0))) / CGFloat(rows))
|
||||
let aspect = tileH / max(tileW, 1)
|
||||
let rem = count % cols
|
||||
let ragged = rem == 0 ? 0 : CGFloat(1.5)
|
||||
let lonely = rem == 1 ? CGFloat(1.4) : 0
|
||||
let tooTall = max(0, aspect - 1.05) * 3
|
||||
let overflow = max(0, needed - offeredH)
|
||||
let score = abs(aspect - 0.78) * 2 + ragged + lonely + tooTall + overflow / 180
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
best = cols
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
static func contentHeight(width: CGFloat, height: CGFloat, count: Int, minWidth: CGFloat, minHeight: CGFloat, spacing: CGFloat) -> CGFloat {
|
||||
let cols = columns(width: width, height: height, count: count, minWidth: minWidth, minHeight: minHeight, spacing: spacing)
|
||||
let rows = max(1, Int(ceil(Double(max(count, 1)) / Double(cols))))
|
||||
return CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0))
|
||||
}
|
||||
}
|
||||
|
||||
struct MacHeaderHeight: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
/// Card grid that grows into the space it is given and scrolls only when the
|
||||
/// minimum tile size no longer fits.
|
||||
/// Fixed-height cards in as many columns as fit, centred and capped in width.
|
||||
struct MacFillGrid<Content: View>: View {
|
||||
var count: Int
|
||||
var minWidth: CGFloat = 240
|
||||
var minHeight: CGFloat = 148
|
||||
var spacing: CGFloat = 16
|
||||
var inset: CGFloat = 28
|
||||
/// Home embeds the grid in its own scroll view. A second scroller here
|
||||
/// swallows the wheel once a banner (the changelog) makes the page taller.
|
||||
var scrolls = true
|
||||
var maxTileWidth: CGFloat = 340
|
||||
@ViewBuilder var content: (Int) -> Content
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let innerW = max(geo.size.width - inset * 2, minWidth)
|
||||
let available = max(geo.size.height - inset * 2, minHeight)
|
||||
let cols = MacGrid.columns(width: innerW, height: available, count: count, minWidth: minWidth, minHeight: minHeight, spacing: spacing)
|
||||
let rows = max(1, Int(ceil(Double(max(count, 1)) / Double(cols))))
|
||||
let floorH = CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0))
|
||||
let gridH = max(available, floorH)
|
||||
let tileH = max(minHeight, (gridH - spacing * CGFloat(max(rows - 1, 0))) / CGFloat(rows))
|
||||
let tileW = (innerW - spacing * CGFloat(max(cols - 1, 0))) / CGFloat(max(cols, 1))
|
||||
let lastCount = count - (rows - 1) * cols
|
||||
let tiles = VStack(spacing: spacing) {
|
||||
ForEach(0..<rows, id: \.self) { row in
|
||||
let stretch = row < rows - 1 || lastCount > 1
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(0..<cols, id: \.self) { col in
|
||||
let index = row * cols + col
|
||||
if index < count {
|
||||
content(index)
|
||||
.frame(
|
||||
maxWidth: stretch ? .infinity : tileW,
|
||||
minHeight: tileH,
|
||||
maxHeight: tileH,
|
||||
alignment: .topLeading
|
||||
)
|
||||
let available = max(geo.size.width - inset * 2, minWidth)
|
||||
let fit = max(1, Int((available + spacing) / (minWidth + spacing)))
|
||||
let cols = max(1, min(count, fit))
|
||||
let width = min(available, CGFloat(cols) * maxTileWidth + CGFloat(cols - 1) * spacing)
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: spacing, alignment: .top), count: cols),
|
||||
alignment: .leading,
|
||||
spacing: spacing
|
||||
) {
|
||||
ForEach(0..<count, id: \.self) { i in
|
||||
content(i).frame(height: minHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(height: gridH, alignment: .top)
|
||||
.frame(width: width)
|
||||
.padding(inset)
|
||||
.frame(maxWidth: .infinity, minHeight: scrolls ? geo.size.height : nil, alignment: .top)
|
||||
|
||||
if scrolls {
|
||||
ScrollView { tiles }
|
||||
.scrollDisabled(floorH + inset * 2 <= geo.size.height + 1)
|
||||
} else {
|
||||
tiles
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.softAppear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,45 +100,103 @@ struct MacLinkCard: View {
|
|||
var badge: String? = nil
|
||||
var systemImage: String? = nil
|
||||
var locked = false
|
||||
var progress: (done: Int, total: Int)? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
let tint = locked ? Color.secondary : Color.accentColor
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .center) {
|
||||
if let badge {
|
||||
Text(badge)
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundStyle(locked ? Color.secondary : Color.accentColor)
|
||||
}
|
||||
Group {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundStyle(locked ? Color.secondary : Color.accentColor)
|
||||
.font(.title3.weight(.semibold))
|
||||
} else if let badge {
|
||||
Text(badge.hasSuffix(".") ? String(badge.dropLast()) : badge)
|
||||
.font(.title3.weight(.bold))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
Spacer(minLength: 0)
|
||||
if locked {
|
||||
Image(systemName: "lock.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Image(systemName: locked ? "lock.fill" : "chevron.right")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(locked ? .secondary : .primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(2)
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(22)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
if let progress, progress.total > 0 {
|
||||
HStack(spacing: 10) {
|
||||
ProgressBar(value: Double(progress.done) / Double(progress.total))
|
||||
Text("\(progress.done) / \(progress.total)")
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.softSurface(cornerRadius: 22)
|
||||
.softSurface(cornerRadius: 20)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Flat capsule bar. Unlike ProgressView it shows nothing at zero.
|
||||
struct ProgressBar: View {
|
||||
let value: Double
|
||||
var height: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.primary.opacity(0.08))
|
||||
if value > 0 {
|
||||
Capsule()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: max(height, geo.size.width * min(value, 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
.animation(Motion.settle, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProgressRing<Label: View>: View {
|
||||
let value: Double
|
||||
var lineWidth: CGFloat = 12
|
||||
@ViewBuilder var label: () -> Label
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().stroke(Color.primary.opacity(0.08), lineWidth: lineWidth)
|
||||
if value > 0 {
|
||||
Circle()
|
||||
.trim(from: 0, to: min(value, 1))
|
||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
label()
|
||||
}
|
||||
.animation(Motion.settle, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 6
|
||||
|
||||
|
|
@ -574,17 +570,30 @@ extension View {
|
|||
#endif
|
||||
}
|
||||
|
||||
/// Full-width, leading-aligned content for exercise screens.
|
||||
/// Leading-aligned content for exercise screens; on the Mac a centred readable column.
|
||||
func exerciseContent() -> some View {
|
||||
frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if os(macOS)
|
||||
frame(maxWidth: MacChrome.readableWidth, alignment: .leading)
|
||||
.padding(.horizontal, MacChrome.pageInset)
|
||||
.padding(.vertical, 20)
|
||||
.padding(.vertical, 28)
|
||||
.frame(maxWidth: .infinity)
|
||||
#else
|
||||
frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Caps a Mac page at [width] and centres it; no-op on iOS.
|
||||
@ViewBuilder
|
||||
func macPage(_ width: CGFloat? = nil) -> some View {
|
||||
#if os(macOS)
|
||||
frame(maxWidth: width ?? MacChrome.contentWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
|
||||
/// macOS content lists use the inset style so they sit under the floating
|
||||
/// glass sidebar instead of looking like a second source list.
|
||||
@ViewBuilder
|
||||
|
|
@ -636,7 +645,8 @@ struct BottomAction<Extra: View>: View {
|
|||
}())
|
||||
#if os(macOS)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 10)
|
||||
.padding(.bottom, 14)
|
||||
.frame(maxWidth: MacChrome.actionWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
#else
|
||||
.padding(.top, 8)
|
||||
|
|
@ -645,10 +655,11 @@ struct BottomAction<Extra: View>: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Primary + secondary text sized for desktop lists.
|
||||
/// Primary + secondary text sized for desktop lists, with optional exercise progress.
|
||||
struct ListRowLabel: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
var progress: (done: Int, total: Int)? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
|
|
@ -665,6 +676,25 @@ struct ListRowLabel: View {
|
|||
#endif
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let progress, progress.total > 0 {
|
||||
ProgressLine(done: progress.done, total: progress.total)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slim bar with "done / total" beside it, for list rows.
|
||||
struct ProgressLine: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProgressBar(value: total == 0 ? 0 : Double(done) / Double(total), height: 5)
|
||||
Text("\(done) / \(total)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,21 +12,9 @@ struct ExerciseScaffold<Content: View>: View {
|
|||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
Group {
|
||||
#if os(macOS)
|
||||
GeometryReader { geo in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) { content() }
|
||||
.exerciseContent()
|
||||
.frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .center)
|
||||
}
|
||||
}
|
||||
#else
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) { content() }
|
||||
.exerciseContent()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.detailScreen(title, subtitle: sub)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ struct MapNode: Identifiable {
|
|||
let current: Bool
|
||||
var finish: Bool = false
|
||||
var destination: Route?
|
||||
/// Glyph for a node that has no number, such as a side branch.
|
||||
var symbol: String? = nil
|
||||
}
|
||||
|
||||
private struct NodePos {
|
||||
|
|
@ -256,6 +258,8 @@ struct PathMap: View {
|
|||
.overlay {
|
||||
if node.locked {
|
||||
Circle().strokeBorder(palette.lockedBorder, lineWidth: 1)
|
||||
} else if !node.current && !node.done {
|
||||
Circle().strokeBorder(palette.accent.opacity(0.55), lineWidth: 2.5)
|
||||
}
|
||||
}
|
||||
if node.finish {
|
||||
|
|
@ -298,6 +302,13 @@ struct PathMap: View {
|
|||
#else
|
||||
.font(.headline.weight(.black))
|
||||
#endif
|
||||
} else if let symbol = node.symbol {
|
||||
Image(systemName: symbol)
|
||||
#if os(macOS)
|
||||
.font(.title3.weight(.semibold))
|
||||
#else
|
||||
.font(.body.weight(.semibold))
|
||||
#endif
|
||||
}
|
||||
Image(systemName: "checkmark")
|
||||
#if os(macOS)
|
||||
|
|
@ -315,6 +326,14 @@ struct PathMap: View {
|
|||
.font(.headline.weight(.black))
|
||||
#endif
|
||||
.foregroundStyle(node.current ? palette.onAccent : palette.text)
|
||||
} else if let symbol = node.symbol {
|
||||
Image(systemName: symbol)
|
||||
#if os(macOS)
|
||||
.font(.title2.weight(.semibold))
|
||||
#else
|
||||
.font(.headline.weight(.semibold))
|
||||
#endif
|
||||
.foregroundStyle(node.current ? palette.onAccent : palette.text)
|
||||
}
|
||||
}
|
||||
.frame(width: nodeSize, height: nodeSize)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue