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