Rework the mobile UIs on native components and fix layout bugs everywhere.

Android moves to Material 3 (navigation bar, app bars, list items,
bottom sheets, pager) with a real back stack. iOS hides the tab bar in
exercises so the bottom action is reachable, aligns content, and uses
system colours so themes and dark mode work. Both pick a readable
accent tint. Desktop keeps the VLSM table inside the window and gives
statistics the same header rhythm as other pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-18 15:37:15 +02:00
commit 0621311b4c
25 changed files with 1965 additions and 1836 deletions

View file

@ -13,8 +13,8 @@ android {
applicationId = "org.maturita.maturita"
minSdk = 26
targetSdk = 35
versionCode = 5
versionName = "android-5"
versionCode = 6
versionName = "android-6"
buildConfigField("String", "COMMIT", "\"$commit\"")
buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"")
buildConfigField("String", "UPDATE_BRANCH", "\"builds\"")

View file

@ -26,7 +26,6 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
private set
var lang by mutableStateOf(progress.lang)
private set
var settingsOpen by mutableStateOf(false)
var searchOpen by mutableStateOf(false)
var searchQuery by mutableStateOf("")
var tick by mutableStateOf(0)
@ -34,19 +33,32 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
var update by mutableStateOf(UpdateState())
private set
private val stack = mutableStateListOf<Route>(Route.Welcome)
private val stack = mutableStateListOf<Route>(Route.Subjects)
val route: Route get() = stack.last()
val canGoBack: Boolean get() = stack.size > 1
val root: Route get() = stack.first()
val canGoBack: Boolean get() = stack.size > 1 || root != Route.Subjects
val palette get() = themePalette(themeId, mode)
fun tr(key: String?) = content.tr(key, lang)
fun fmt(key: String, vararg args: Any) = content.fmt(key, lang, *args)
/** Roots switch the bottom tab; everything else is pushed on the practice stack. */
fun go(r: Route) {
if (stack.last() == r) return
stack.add(r)
searchOpen = false
if (stack.last() == r) return
if (r.isRoot) {
val target = if (r == Route.Welcome) Route.Subjects else r
stack.clear()
stack.add(target)
return
}
if (root != Route.Subjects) {
stack.clear()
stack.addAll(r.stack())
return
}
stack.add(r)
}
fun goPage(page: String?) {
@ -54,8 +66,25 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
go(r)
}
fun back() {
/** Jumping from search rebuilds the whole path so back walks up level by level. */
fun openPage(page: String) {
val r = routeFromPage(page) ?: return
searchOpen = false
stack.clear()
stack.addAll(r.stack())
}
/** Replace the current screen (lesson slides -> its exercise). */
fun replace(r: Route) {
if (stack.size > 1) stack.removeAt(stack.lastIndex)
stack.add(r)
}
fun back() {
when {
stack.size > 1 -> stack.removeAt(stack.lastIndex)
root != Route.Subjects -> go(Route.Subjects)
}
}
fun setTheme(id: ThemeId) {

View file

@ -12,56 +12,39 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.graphics.Color
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.data.Palette
import org.maturita.maturita.ui.BookListScreen
import org.maturita.maturita.ui.BookScreen
import org.maturita.maturita.ui.CzechMapScreen
import org.maturita.maturita.ui.GermanExercise
import org.maturita.maturita.ui.HomeScreen
import org.maturita.maturita.ui.HwQuizScreen
import org.maturita.maturita.ui.IconHit
import org.maturita.maturita.ui.LessonMapScreen
import org.maturita.maturita.ui.LessonListScreen
import org.maturita.maturita.ui.LessonRow
import org.maturita.maturita.ui.LitQuizScreen
import org.maturita.maturita.ui.MapNode
import org.maturita.maturita.ui.MluvExercise
import org.maturita.maturita.ui.NetQuizScreen
import org.maturita.maturita.ui.NetYearsScreen
import org.maturita.maturita.ui.PlotScreen
import org.maturita.maturita.ui.RoadmapScreen
import org.maturita.maturita.ui.SearchIcon
import org.maturita.maturita.ui.SearchOverlay
import org.maturita.maturita.ui.SettingsIcon
import org.maturita.maturita.ui.SettingsSheet
import org.maturita.maturita.ui.SearchScreen
import org.maturita.maturita.ui.SettingsScreen
import org.maturita.maturita.ui.SlidesScreen
import org.maturita.maturita.ui.appTextStyle
import org.maturita.maturita.ui.appTypography
import org.maturita.maturita.ui.StatsIcon
import org.maturita.maturita.ui.StatsScreen
import org.maturita.maturita.ui.SubjectsScreen
import org.maturita.maturita.ui.UnitMapScreen
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.ui.VocabExercise
import org.maturita.maturita.ui.WelcomeScreen
import org.maturita.maturita.ui.appTypography
class MainActivity : ComponentActivity() {
private val vm: AppViewModel by viewModels()
@ -125,165 +108,136 @@ class MainActivity : ComponentActivity() {
}
}
/** Material 3 colour roles from the shared theme palette. */
private fun schemeFor(p: Palette, dark: Boolean) = if (dark) {
darkColorScheme(
primary = p.tint,
onPrimary = p.onTint,
primaryContainer = p.accent,
onPrimaryContainer = p.onAccent,
secondary = p.accent2,
onSecondary = p.onAccent,
secondaryContainer = p.surface1,
onSecondaryContainer = p.text,
tertiary = p.warning,
onTertiary = p.crust,
background = p.base,
onBackground = p.text,
surface = p.base,
onSurface = p.text,
surfaceVariant = p.surface0,
onSurfaceVariant = p.subtext,
surfaceContainerLowest = p.crust,
surfaceContainerLow = p.mantle,
surfaceContainer = p.mantle,
surfaceContainerHigh = p.surface0,
surfaceContainerHighest = p.surface1,
outline = p.overlay,
outlineVariant = p.surface1,
error = p.error,
onError = Color.White,
errorContainer = p.error.copy(alpha = 0.22f),
onErrorContainer = p.text,
)
} else {
lightColorScheme(
primary = p.tint,
onPrimary = p.onTint,
primaryContainer = p.accent,
onPrimaryContainer = p.onAccent,
secondary = p.accent2,
onSecondary = Color.White,
secondaryContainer = p.surface1,
onSecondaryContainer = p.text,
tertiary = p.warning,
onTertiary = Color.White,
background = p.base,
onBackground = p.text,
surface = p.base,
onSurface = p.text,
surfaceVariant = p.surface1,
onSurfaceVariant = p.subtext,
surfaceContainerLowest = p.mantle,
surfaceContainerLow = p.mantle,
surfaceContainer = p.mantle,
surfaceContainerHigh = p.surface1,
surfaceContainerHighest = p.surface2,
outline = p.overlay,
outlineVariant = p.surface2,
error = p.error,
onError = Color.White,
errorContainer = p.error.copy(alpha = 0.14f),
onErrorContainer = p.text,
)
}
@Composable
fun MaturitaApp(vm: AppViewModel) {
val p = vm.palette
val scheme = if (vm.mode == ColorMode.Dark) {
darkColorScheme(
primary = p.accent,
onPrimary = p.onAccent,
secondary = p.accent2,
background = p.base,
onBackground = p.text,
surface = p.mantle,
onSurface = p.text,
surfaceVariant = p.surface1,
onSurfaceVariant = p.subtext,
error = p.error,
)
} else {
lightColorScheme(
primary = p.accent,
onPrimary = p.onAccent,
secondary = p.accent2,
background = p.base,
onBackground = p.text,
surface = p.mantle,
onSurface = p.text,
surfaceVariant = p.surface1,
onSurfaceVariant = p.subtext,
error = p.error,
)
}
MaterialTheme(colorScheme = scheme, typography = appTypography()) {
MaturitaAppBody(vm)
MaterialTheme(colorScheme = schemeFor(vm.palette, vm.mode == ColorMode.Dark), typography = appTypography()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MaturitaAppBody(vm)
}
}
}
@Composable
private fun MaturitaAppBody(vm: AppViewModel) {
val p = vm.palette
LaunchedEffect(Unit) { vm.checkUpdate(false) }
BackHandler(vm.canGoBack || vm.searchOpen || vm.settingsOpen) {
when {
vm.searchOpen -> vm.searchOpen = false
vm.settingsOpen -> vm.settingsOpen = false
else -> vm.back()
}
BackHandler(vm.canGoBack || vm.searchOpen) {
if (vm.searchOpen) vm.searchOpen = false else vm.back()
}
Box(
Modifier
.fillMaxSize()
.background(p.base)
.statusBarsPadding()
.navigationBarsPadding(),
) {
Column(Modifier.fillMaxSize()) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("maturita.c", style = appTextStyle(15.sp, FontWeight.Medium, color = p.text), modifier = Modifier.weight(1f))
IconHit({ vm.searchOpen = true }) { SearchIcon(p.text) }
IconHit({ vm.go(Route.Stats) }) { StatsIcon(p.text) }
IconHit({ vm.settingsOpen = true }) { SettingsIcon(p.text) }
}
if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(14.dp))
.background(p.surface1)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())),
color = p.text,
fontSize = 13.sp,
modifier = Modifier.weight(1f),
)
if (vm.update.canInstall) {
org.maturita.maturita.ui.PillButton(vm.tr("update_install"), p) { vm.installUpdate() }
}
Box(Modifier.fillMaxSize()) {
when (val r = vm.route) {
Route.Welcome, Route.Subjects -> HomeScreen(vm)
Route.Stats -> StatsScreen(vm)
Route.Settings -> SettingsScreen(vm)
Route.Roadmap -> RoadmapScreen(vm)
is Route.UnitMap -> UnitMapScreen(vm, r.unitId)
is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex)
is Route.Vocab -> VocabExercise(vm, r.unitId)
Route.NetYears -> NetYearsScreen(vm)
Route.NetMap -> LessonListScreen(
vm, vm.tr("net_year1"), vm.tr("net_sub"),
vm.content.netLessons.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.netDone(id), Route.NetLesson(id))
},
)
is Route.NetLesson -> {
val l = vm.content.netLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.NetEx(r.id))
}
}
Box(Modifier.weight(1f)) {
when (val r = vm.route) {
Route.Welcome -> WelcomeScreen(vm)
Route.Subjects -> SubjectsScreen(vm)
Route.Stats -> StatsScreen(vm)
Route.Roadmap -> RoadmapScreen(vm)
is Route.UnitMap -> UnitMapScreen(vm, r.unitId)
is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex)
is Route.Vocab -> VocabExercise(vm, r.unitId)
Route.NetYears -> NetYearsScreen(vm)
Route.NetMap -> LessonMapScreen(
vm, vm.tr("net_year1"), vm.tr("net_sub"),
vm.content.netLessons.map { l ->
val id = l.int("id")
MapNode(
"n$id", vm.tr(l.str("titleKey")), false,
vm.progress.netDone(id), !vm.progress.netDone(id),
) { vm.go(Route.NetLesson(id)) }
},
vm.content.netLessons.count { vm.progress.netDone(it.int("id")) }.toFloat(),
)
is Route.NetLesson -> {
val l = vm.content.netLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.go(Route.NetEx(r.id))
}
}
is Route.NetEx -> NetQuizScreen(vm, r.id)
Route.HwMap -> LessonMapScreen(
vm, vm.tr("Technické vybavení"), vm.tr("hw_sub"),
vm.content.hw.map { l ->
val id = l.int("id")
MapNode(
"h$id", vm.tr(l.str("titleKey")), false,
vm.progress.hwDone(id), !vm.progress.hwDone(id),
) { vm.go(Route.HwLesson(id)) }
},
vm.content.hw.count { vm.progress.hwDone(it.int("id")) }.toFloat(),
)
is Route.HwLesson -> {
val l = vm.content.hwLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.go(Route.HwEx(r.id))
}
}
is Route.HwEx -> HwQuizScreen(vm, r.id)
Route.CzechMap -> CzechMapScreen(vm)
Route.Mluvnice -> LessonMapScreen(
vm, vm.tr("Mluvnice"), "Cvičení z mluvnice – styl maturita / přijímačky z ČJL",
vm.content.mluvnice.map { m ->
val n = m.int("id")
MapNode(
"m$n", m.str("name"), false,
vm.progress.mluvDone(n), !vm.progress.mluvDone(n),
) { vm.go(Route.MluvEx(n)) }
},
vm.content.mluvnice.count { vm.progress.mluvDone(it.int("id")) }.toFloat(),
)
is Route.MluvEx -> MluvExercise(vm, r.n)
Route.ReadingList -> BookListScreen(vm)
is Route.Book -> BookScreen(vm, r.id)
is Route.BookQuiz -> LitQuizScreen(vm, r.id)
is Route.BookPlot -> PlotScreen(vm, r.id)
is Route.NetEx -> NetQuizScreen(vm, r.id)
Route.HwMap -> LessonListScreen(
vm, vm.tr("Technické vybavení"), vm.tr("hw_sub"),
vm.content.hw.map { l ->
val id = l.int("id")
LessonRow(vm.tr(l.str("titleKey")), vm.progress.hwDone(id), Route.HwLesson(id))
},
)
is Route.HwLesson -> {
val l = vm.content.hwLesson(r.id)
if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm.replace(Route.HwEx(r.id))
}
}
is Route.HwEx -> HwQuizScreen(vm, r.id)
Route.CzechMap -> CzechMapScreen(vm)
Route.Mluvnice -> LessonListScreen(
vm, vm.tr("Mluvnice"), vm.tr("mluv_sub"),
vm.content.mluvnice.map { m ->
val n = m.int("id")
LessonRow(m.str("name"), vm.progress.mluvDone(n), Route.MluvEx(n))
},
)
is Route.MluvEx -> MluvExercise(vm, r.n)
Route.ReadingList -> BookListScreen(vm)
is Route.Book -> BookScreen(vm, r.id)
is Route.BookQuiz -> LitQuizScreen(vm, r.id)
is Route.BookPlot -> PlotScreen(vm, r.id)
}
if (vm.searchOpen) SearchOverlay(vm)
if (vm.settingsOpen) {
Box(
Modifier
.fillMaxSize()
.background(p.base),
) { SettingsSheet(vm) }
}
if (vm.searchOpen) SearchScreen(vm)
}
}

View file

@ -4,6 +4,7 @@ sealed class Route(val page: String) {
data object Welcome : Route("welcome")
data object Subjects : Route("subjects")
data object Stats : Route("stats")
data object Settings : Route("settings")
data object Roadmap : Route("roadmap")
data class UnitMap(val unitId: Int) : Route("unit${unitId + 1}")
data class GermanEx(val unitId: Int, val ex: Int) : Route("u${unitId + 1}e$ex")
@ -22,12 +23,46 @@ sealed class Route(val page: String) {
data class Book(val id: String) : Route(if (id == "1984") "cetba1984" else "cetbaFuks")
data class BookQuiz(val id: String) : Route(if (id == "1984") "cetba1984quiz" else "cetbaFuksQuiz")
data class BookPlot(val id: String) : Route(if (id == "1984") "cetba1984dej" else "cetbaFuksDej")
/** Bottom-navigation roots; everything else is pushed on top of [Subjects]. */
val isRoot: Boolean get() = this is Welcome || this is Subjects || this is Stats || this is Settings
/** Screens with their own bottom action bar; the navigation bar stays hidden there. */
val isExercise: Boolean
get() = this is GermanEx || this is Vocab || this is NetEx || this is HwEx ||
this is MluvEx || this is BookQuiz || this is BookPlot || this is NetLesson || this is HwLesson
/** Full stack from the practice root to this screen, so back always goes up one level. */
fun stack(): List<Route> = when (this) {
Welcome, Subjects -> listOf(Subjects)
Stats -> listOf(Stats)
Settings -> listOf(Settings)
Roadmap -> listOf(Subjects, Roadmap)
is UnitMap -> listOf(Subjects, Roadmap, this)
is GermanEx -> listOf(Subjects, Roadmap, UnitMap(unitId), this)
is Vocab -> listOf(Subjects, Roadmap, UnitMap(unitId), this)
NetYears -> listOf(Subjects, NetYears)
NetMap -> listOf(Subjects, NetYears, NetMap)
is NetLesson -> listOf(Subjects, NetYears, NetMap, this)
is NetEx -> listOf(Subjects, NetYears, NetMap, NetLesson(id), this)
HwMap -> listOf(Subjects, HwMap)
is HwLesson -> listOf(Subjects, HwMap, this)
is HwEx -> listOf(Subjects, HwMap, HwLesson(id), this)
CzechMap -> listOf(Subjects, CzechMap)
Mluvnice -> listOf(Subjects, CzechMap, Mluvnice)
is MluvEx -> listOf(Subjects, CzechMap, Mluvnice, this)
ReadingList -> listOf(Subjects, CzechMap, ReadingList)
is Book -> listOf(Subjects, CzechMap, ReadingList, this)
is BookQuiz -> listOf(Subjects, CzechMap, ReadingList, Book(id), this)
is BookPlot -> listOf(Subjects, CzechMap, ReadingList, Book(id), this)
}
}
fun routeFromPage(page: String): Route? = when (page) {
"welcome" -> Route.Welcome
"subjects" -> Route.Subjects
"stats" -> Route.Stats
"settings" -> Route.Settings
"roadmap" -> Route.Roadmap
"netyears" -> Route.NetYears
"netmap" -> Route.NetMap

View file

@ -36,16 +36,48 @@ data class Palette(
val finishDark: Color,
val finishLight: Color,
val finishStroke: Color,
/** Accent that stays readable as text and icon tint on [base]. */
val tint: Color,
/** Text colour that reads on [tint]. */
val onTint: Color,
)
private fun hx(v: Long) = Color(0xFF000000L or v)
private fun pal(vararg c: Long) = Palette(
hx(c[0]), hx(c[1]), hx(c[2]), hx(c[3]), hx(c[4]), hx(c[5]), hx(c[6]),
hx(c[7]), hx(c[8]), hx(c[9]), hx(c[10]), hx(c[11]), hx(c[12]), hx(c[13]),
hx(c[14]), hx(c[15]), hx(c[16]), hx(c[17]), hx(c[18]), hx(c[19]), hx(c[20]),
hx(c[21]), hx(c[22]), hx(c[23]),
)
private fun luminance(v: Long): Double {
fun channel(c: Long): Double {
val s = c / 255.0
return if (s <= 0.03928) s / 12.92 else Math.pow((s + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel((v shr 16) and 0xFF) + 0.7152 * channel((v shr 8) and 0xFF) + 0.0722 * channel(v and 0xFF)
}
private fun contrast(a: Long, b: Long): Double {
val la = luminance(a)
val lb = luminance(b)
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
}
/** Yellow on white is unreadable as link text; fall back to the next accent. */
private fun readableTint(c: LongArray): Long {
val background = c[1]
for (candidate in longArrayOf(c[9], c[10], c[11])) {
if (contrast(candidate, background) >= 3.0) return candidate
}
return c[7]
}
private fun pal(vararg c: Long): Palette {
val tint = readableTint(c)
return Palette(
hx(c[0]), hx(c[1]), hx(c[2]), hx(c[3]), hx(c[4]), hx(c[5]), hx(c[6]),
hx(c[7]), hx(c[8]), hx(c[9]), hx(c[10]), hx(c[11]), hx(c[12]), hx(c[13]),
hx(c[14]), hx(c[15]), hx(c[16]), hx(c[17]), hx(c[18]), hx(c[19]), hx(c[20]),
hx(c[21]), hx(c[22]), hx(c[23]),
tint = hx(tint),
onTint = if (luminance(tint) > 0.4) Color(0xFF111111) else Color.White,
)
}
fun themePalette(id: ThemeId, mode: ColorMode): Palette {
val light = mode == ColorMode.Light

View file

@ -1,38 +1,77 @@
package org.maturita.maturita.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.material3.Typography
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontSynthesis
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.maturita.maturita.AppViewModel
import org.maturita.maturita.data.Palette
import org.maturita.maturita.Route
/** Bold is capped at Medium: synthetic bold doubled glyphs on some devices. */
fun appTextStyle(
fontSize: TextUnit,
fontWeight: FontWeight = FontWeight.Normal,
@ -46,186 +85,379 @@ fun appTextStyle(
fontWeight = weight,
fontFamily = FontFamily.SansSerif,
fontSynthesis = FontSynthesis.None,
lineHeight = fontSize * 1.4f,
lineHeight = fontSize * 1.35f,
letterSpacing = 0.sp,
textAlign = textAlign,
)
}
fun appTypography(): Typography {
val base = appTextStyle(16.sp)
return Typography(
bodyLarge = base,
bodyMedium = appTextStyle(14.sp),
bodySmall = appTextStyle(12.sp),
titleLarge = appTextStyle(22.sp, FontWeight.Medium),
titleMedium = appTextStyle(18.sp, FontWeight.Medium),
titleSmall = appTextStyle(16.sp, FontWeight.Medium),
labelLarge = appTextStyle(14.sp, FontWeight.Medium),
labelMedium = appTextStyle(12.sp, FontWeight.Medium),
headlineMedium = appTextStyle(24.sp, FontWeight.Medium),
fun appTypography(): Typography = Typography(
displaySmall = appTextStyle(34.sp, FontWeight.Medium),
headlineLarge = appTextStyle(30.sp, FontWeight.Medium),
headlineMedium = appTextStyle(26.sp, FontWeight.Medium),
headlineSmall = appTextStyle(22.sp, FontWeight.Medium),
titleLarge = appTextStyle(20.sp, FontWeight.Medium),
titleMedium = appTextStyle(16.sp, FontWeight.Medium),
titleSmall = appTextStyle(14.sp, FontWeight.Medium),
bodyLarge = appTextStyle(16.sp),
bodyMedium = appTextStyle(14.sp),
bodySmall = appTextStyle(12.sp),
labelLarge = appTextStyle(14.sp, FontWeight.Medium),
labelMedium = appTextStyle(12.sp, FontWeight.Medium),
labelSmall = appTextStyle(11.sp, FontWeight.Medium),
)
// ---------------------------------------------------------------------------
// Chrome
@Composable
fun AppNavBar(vm: AppViewModel) {
val root = vm.root
NavigationBar {
NavigationBarItem(
selected = root == Route.Subjects,
onClick = { vm.go(Route.Subjects) },
icon = { SubjectsIcon(navIconColor(root == Route.Subjects)) },
label = { Text(vm.tr("subjects_title"), maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
NavigationBarItem(
selected = root == Route.Stats,
onClick = { vm.go(Route.Stats) },
icon = { StatsIcon(navIconColor(root == Route.Stats), 22.dp) },
label = { Text(vm.tr("stats"), maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
NavigationBarItem(
selected = root == Route.Settings,
onClick = { vm.go(Route.Settings) },
icon = { SettingsIcon(navIconColor(root == Route.Settings), 22.dp) },
label = { Text(vm.tr("settings"), maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
}
@Composable
private fun navIconColor(selected: Boolean): Color =
if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
/** Tab root: large collapsing title, search action, bottom navigation. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootScaffold(
vm: AppViewModel,
title: String,
showSearch: Boolean = true,
content: LazyListScope.() -> Unit,
) {
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
actions = {
if (showSearch) {
IconButton(onClick = { vm.searchOpen = true }) {
Icon(Icons.Filled.Search, contentDescription = vm.tr("search_placeholder"))
}
}
},
scrollBehavior = scroll,
)
},
bottomBar = { AppNavBar(vm) },
) { inner ->
LazyColumn(
contentPadding = PaddingValues(
top = inner.calculateTopPadding(),
bottom = inner.calculateBottomPadding() + 16.dp,
),
) { content() }
}
}
/** Pushed screen: back arrow, title with optional subtitle, optional bottom action bar. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailScaffold(
vm: AppViewModel,
title: String,
subtitle: String? = null,
bottomBar: (@Composable () -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit,
) {
val scroll = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Column {
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleLarge)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = { vm.back() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = vm.tr("back"))
}
},
scrollBehavior = scroll,
)
},
bottomBar = { bottomBar?.invoke() },
content = content,
)
}
private val Pill = RoundedCornerShape(999.dp)
private val Card = RoundedCornerShape(20.dp)
/** Feedback line plus the primary action, anchored at the bottom of an exercise. */
@Composable
fun PrimaryButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(
modifier
.clip(Pill)
.background(palette.accent)
.clickable(onClick = onClick)
.padding(horizontal = 22.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(label, style = appTextStyle(15.sp, FontWeight.Medium, color = palette.onAccent))
}
}
@Composable
fun PillButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(
modifier
.clip(Pill)
.background(palette.text.copy(alpha = 0.06f))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(label, style = appTextStyle(13.sp, FontWeight.Medium, color = palette.text))
}
}
@Composable
fun IconHit(onClick: () -> Unit, content: @Composable () -> Unit) {
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) { content() }
}
@Composable
fun PageTop(vm: AppViewModel, back: () -> Unit, title: String, subtitle: String? = null) {
val p = vm.palette
Column(Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 14.dp)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Box(Modifier.align(Alignment.CenterStart)) {
IconHit(back) { BackIcon(p.text) }
fun ActionBar(
label: String,
feedback: String = "",
kind: String = "",
enabled: Boolean = true,
leading: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 2.dp) {
Column(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
if (feedback.isNotEmpty()) {
FeedbackLine(feedback, kind)
Spacer(Modifier.height(10.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
leading?.invoke()
Button(onClick = onClick, enabled = enabled, modifier = Modifier.weight(1f).height(50.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium)
}
}
Text(
title,
modifier = Modifier.padding(horizontal = 48.dp),
style = appTextStyle(22.sp, FontWeight.Medium, TextAlign.Center, p.text),
)
}
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
style = appTextStyle(14.sp, textAlign = TextAlign.Center, color = p.subtext),
)
}
}
}
// ---------------------------------------------------------------------------
// Lists
@Composable
fun CardBox(palette: Palette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier
.clip(Card)
.background(palette.mantle)
.border(1.dp, palette.text.copy(alpha = 0.07f), Card)
.padding(20.dp),
) { content() }
fun SectionHeader(text: String) {
Text(
text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 6.dp),
)
}
/** One navigable row: done/pending/locked state on the left, chevron on the right. */
@Composable
fun NavRow(
title: String,
subtitle: String? = null,
done: Boolean = false,
locked: Boolean = false,
leading: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)?,
) {
val enabled = !locked && onClick != null
val textColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
val rowModifier = if (enabled) Modifier.clickable(onClick = onClick!!) else Modifier
ListItem(
headlineContent = { Text(title, color = textColor) },
supportingContent = subtitle?.takeIf { it.isNotBlank() }?.let {
{ Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
},
leadingContent = {
when {
leading != null -> leading()
locked -> Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.outline)
done -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
else -> Icon(Icons.Filled.RadioButtonUnchecked, null, tint = MaterialTheme.colorScheme.outline)
}
},
trailingContent = if (enabled) {
{ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = MaterialTheme.colorScheme.outline) }
} else null,
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = rowModifier,
)
}
// ---------------------------------------------------------------------------
// Exercise widgets
@Composable
fun PrimaryButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
Button(onClick = onClick, modifier = modifier) { Text(label) }
}
@Composable
fun FeedbackLine(text: String, kind: String, palette: Palette) {
fun PillButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
FilledTonalButton(onClick = onClick, modifier = modifier) { Text(label) }
}
@Composable
fun FeedbackLine(text: String, kind: String) {
if (text.isEmpty()) return
val color = when (kind) {
"ok" -> palette.success
"warn" -> palette.warning
else -> palette.error
"ok" -> MaterialTheme.colorScheme.primary
"warn" -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
val icon = when (kind) {
"ok" -> Icons.Filled.CheckCircle
"warn" -> Icons.Filled.Warning
else -> Icons.Filled.Cancel
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = color, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(text, color = color, style = MaterialTheme.typography.titleSmall)
}
Text(text, color = color, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
}
@Composable
fun Meaning(text: String, palette: Palette, visible: Boolean) {
fun Meaning(text: String, visible: Boolean) {
if (!visible || text.isEmpty()) return
Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp))
Text(
text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
)
}
@Composable
fun Hint(text: String) {
Text(
text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
}
@Composable
fun Prompt(text: String) {
Text(text, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp, bottom = 4.dp))
}
@Composable
fun WordField(
value: String,
onValue: (String) -> Unit,
palette: Palette,
placeholder: String,
mark: Boolean? = null,
modifier: Modifier = Modifier,
numeric: Boolean = false,
) {
val border = when (mark) {
true -> palette.success
false -> palette.error
null -> palette.text.copy(alpha = 0.12f)
}
Box(
modifier
.clip(RoundedCornerShape(14.dp))
.background(palette.surface0)
.border(1.dp, border, RoundedCornerShape(14.dp))
.padding(horizontal = 14.dp, vertical = 12.dp)
.fillMaxWidth(),
) {
if (value.isEmpty()) Text(placeholder, color = palette.overlay, fontSize = 15.sp)
BasicTextField(
value = value,
onValueChange = onValue,
textStyle = TextStyle(color = palette.text, fontSize = 15.sp),
cursorBrush = SolidColor(palette.accent),
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun Segmented(palette: Palette, content: @Composable RowScope.() -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clip(Pill)
.background(palette.surface1)
.padding(3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
content = content,
OutlinedTextField(
value = value,
onValueChange = onValue,
placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder) }) else null,
singleLine = true,
isError = mark == false,
trailingIcon = when (mark) {
true -> ({ Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) })
false -> ({ Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error) })
null -> null
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = if (numeric) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
)
}
/** Tappable word in a bank or a built sentence. */
@Composable
fun SegChip(label: String, selected: Boolean, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(
modifier
.clip(Pill)
.background(if (selected) palette.mantle else Color.Transparent)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
fun Chip(text: String, onClick: () -> Unit) {
SuggestionChip(onClick = onClick, label = { Text(text) })
}
@Composable
fun SegChip(label: String, selected: Boolean, onClick: () -> Unit) {
FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
}
/** Multiple-choice answer with radio state and, after checking, a correct/wrong mark. */
@Composable
fun OptionRow(text: String, selected: Boolean, mark: Boolean?, enabled: Boolean = true, onClick: () -> Unit) {
val container = when (mark) {
true -> MaterialTheme.colorScheme.primaryContainer
false -> MaterialTheme.colorScheme.errorContainer
null -> if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh
}
Surface(
onClick = onClick,
enabled = enabled,
color = container,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
Text(label, color = palette.text, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
Row(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
when (mark) {
true -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
false -> Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error)
null -> RadioButton(selected = selected, onClick = null)
}
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
}
}
}
/** Drop zone for word ordering; visible even without words. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Hint(text: String, palette: Palette) {
Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp))
fun WordSlot(empty: Boolean, content: @Composable () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
FlowRow(
Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (empty) Text("…", color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding(8.dp))
content()
}
}
}
/** Bottom sheet with the word bank for combo/dialogue blanks. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Prompt(text: String, palette: Palette) {
Text(text, color = palette.text, fontSize = 16.sp, fontWeight = FontWeight.Medium)
fun WordPicker(title: String, pool: List<String>, onPick: (String?) -> Unit) {
ModalBottomSheet(onDismissRequest = { onPick(null) }) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
LazyColumn(contentPadding = PaddingValues(bottom = 24.dp)) {
items(pool.size) { i ->
ListItem(
headlineContent = { Text(pool[i]) },
modifier = Modifier.clickable { onPick(pool[i]) },
)
}
}
}
}

View file

@ -51,6 +51,18 @@ fun StatsIcon(color: Color, iconSize: Dp = 18.dp) {
}
}
@Composable
fun SubjectsIcon(color: Color, iconSize: Dp = 22.dp) {
DrawnIcon(iconSize) {
val gap = size.minDimension * 0.08f
val cell = (size.minDimension - gap) / 2f
val r = androidx.compose.ui.geometry.CornerRadius(cell * 0.28f)
for (iy in 0 until 2) for (ix in 0 until 2) {
drawRoundRect(color, Offset(ix * (cell + gap), iy * (cell + gap)), Size(cell, cell), r)
}
}
}
@Composable
fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) {
DrawnIcon(iconSize) {

View file

@ -1,108 +0,0 @@
package org.maturita.maturita.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.maturita.maturita.data.Palette
data class MapNode(
val id: String,
val label: String,
val locked: Boolean,
val done: Boolean,
val current: Boolean,
val finish: Boolean = false,
val onClick: (() -> Unit)? = null,
)
@Composable
fun PathMap(
nodes: List<MapNode>,
palette: Palette,
@Suppress("UNUSED_PARAMETER") litUntil: Float,
modifier: Modifier = Modifier,
@Suppress("UNUSED_PARAMETER") nodeSize: Float = 72f,
@Suppress("UNUSED_PARAMETER") mx: Float = 90f,
@Suppress("UNUSED_PARAMETER") my: Float = 110f,
@Suppress("UNUSED_PARAMETER") spac: Float = 170f,
@Suppress("UNUSED_PARAMETER") gap: Float = 170f,
@Suppress("UNUSED_PARAMETER") wave: Float = 28f,
@Suppress("UNUSED_PARAMETER") lift: Float = 0f,
) {
Column(
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
nodes.forEachIndexed { i, node ->
val click = node.onClick
CardBox(
palette,
Modifier
.fillMaxWidth()
.alpha(if (node.locked) 0.48f else 1f)
.then(
if (!node.locked && click != null) Modifier.clickable { click() }
else Modifier,
),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val bg = when {
node.locked -> palette.lockedBg
node.current || node.done -> palette.accent
else -> palette.node
}
val fg = when {
node.locked -> palette.overlay
node.current || node.done -> palette.onAccent
else -> palette.text
}
Box(
Modifier
.size(44.dp)
.clip(CircleShape)
.background(bg),
contentAlignment = Alignment.Center,
) {
when {
node.locked -> LockIcon(palette.overlay, 16.dp)
node.done -> CheckIcon(palette.onAccent, 16.dp)
else -> Text("${i + 1}", color = fg, fontWeight = FontWeight.Black, fontSize = 16.sp)
}
}
Spacer(Modifier.width(12.dp))
Text(
node.label,
color = if (node.locked) palette.overlay else palette.text,
fontWeight = if (node.current) FontWeight.Bold else FontWeight.SemiBold,
fontSize = 16.sp,
modifier = Modifier.weight(1f),
)
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -926,7 +926,7 @@ flowboxchild {
/* ------------------------------------------------------------------ */
.stats-page {
padding: 8px 28px 24px 28px;
padding: 24px 32px 24px 32px;
}
.stats-summary {
margin-top: 10px;

View file

@ -18,7 +18,6 @@
A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; };
A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; };
A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.swift */; };
A101000000000000000012 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000012 /* PathMap.swift */; };
A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; };
A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; };
A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.swift */; };
@ -38,7 +37,6 @@
A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = "<group>"; };
A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = "<group>"; };
A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = "<group>"; };
A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = "<group>"; };
A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = "<group>"; };
@ -108,7 +106,6 @@
children = (
A201000000000000000010 /* Components.swift */,
A201000000000000000011 /* Icons.swift */,
A201000000000000000012 /* PathMap.swift */,
A201000000000000000013 /* Screens.swift */,
A201000000000000000014 /* Exercises.swift */,
A201000000000000000015 /* ExercisesMore.swift */,
@ -230,7 +227,6 @@
A101000000000000000009 /* Updater.swift in Sources */,
A101000000000000000010 /* Components.swift in Sources */,
A101000000000000000011 /* Icons.swift in Sources */,
A101000000000000000012 /* PathMap.swift in Sources */,
A101000000000000000013 /* Screens.swift in Sources */,
A101000000000000000014 /* Exercises.swift in Sources */,
A101000000000000000015 /* ExercisesMore.swift in Sources */,

View file

@ -19,6 +19,18 @@ final class AppModel: ObservableObject {
themeId = progress.themeId
mode = progress.mode
lang = progress.lang
#if DEBUG
// `xcrun simctl launch <udid> org.maturita.maturita -route u1e3` opens a page for screenshots.
if let page = UserDefaults.standard.string(forKey: "route"), let r = routeFromPage(page) {
openFromSearch(r.page)
}
if let theme = UserDefaults.standard.string(forKey: "theme"), let n = Int(theme), let id = ThemeId(rawValue: n) {
themeId = id
}
if let m = UserDefaults.standard.string(forKey: "mode") {
mode = m == "dark" ? .dark : .light
}
#endif
}
var palette: Palette { themePalette(themeId, mode) }

View file

@ -24,6 +24,8 @@ struct Palette {
let success, success2, warning, error: Color
let onAccent, node, lockedBg, lockedBorder, rail: Color
let finishDark, finishLight, finishStroke: Color
/// Accent that stays readable as text and icon tint on the theme's background.
let tint: Color
}
private func hx(_ v: UInt32) -> Color {
@ -34,6 +36,29 @@ private func hx(_ v: UInt32) -> Color {
)
}
private func luminance(_ v: UInt32) -> Double {
func channel(_ c: UInt32) -> Double {
let s = Double(c) / 255
return s <= 0.03928 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel((v >> 16) & 0xFF) + 0.7152 * channel((v >> 8) & 0xFF) + 0.0722 * channel(v & 0xFF)
}
private func contrast(_ a: UInt32, _ b: UInt32) -> Double {
let la = luminance(a), lb = luminance(b)
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
}
/// Pick the first accent with enough contrast against the background; yellow on
/// white is unreadable as tab labels and link text.
private func readableTint(_ c: [UInt32]) -> UInt32 {
let background = c[1]
for candidate in [c[9], c[10], c[11]] where contrast(candidate, background) >= 3.0 {
return candidate
}
return c[7]
}
private func pal(_ c: [UInt32]) -> Palette {
Palette(
crust: hx(c[0]), base: hx(c[1]), mantle: hx(c[2]), surface0: hx(c[3]),
@ -42,7 +67,8 @@ private func pal(_ c: [UInt32]) -> Palette {
success: hx(c[12]), success2: hx(c[13]), warning: hx(c[14]), error: hx(c[15]),
onAccent: hx(c[16]), node: hx(c[17]), lockedBg: hx(c[18]),
lockedBorder: hx(c[19]), rail: hx(c[20]), finishDark: hx(c[21]),
finishLight: hx(c[22]), finishStroke: hx(c[23])
finishLight: hx(c[22]), finishStroke: hx(c[23]),
tint: hx(readableTint(c))
)
}

View file

@ -22,7 +22,7 @@ struct NativeRoot: View {
tabs
}
}
.tint(vm.palette.accent)
.tint(vm.palette.tint)
.preferredColorScheme(vm.mode == .dark ? .dark : .light)
.onAppear { vm.checkUpdate(false) }
}
@ -151,7 +151,7 @@ struct RouteDestination: View {
case .mluvnice:
LessonListScreen(
title: vm.tr("Mluvnice"),
subtitle: "Cvičení z mluvnice",
subtitle: vm.tr("mluv_sub"),
rows: vm.content.mluvnice.map { m in
let n = m.int("id")
return LessonRow(

View file

@ -44,9 +44,13 @@ struct PrimaryButton: View {
var action: () -> Void
var body: some View {
Button(label, action: action)
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button(action: action) {
Text(label)
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
}
.buttonStyle(.glassProminent)
.controlSize(.large)
}
}
@ -78,9 +82,13 @@ struct FeedbackLine: View {
var body: some View {
if !text.isEmpty {
Text(text)
.font(.subheadline.weight(.semibold))
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
Label {
Text(text)
} icon: {
Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill")
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
}
}
}
@ -107,21 +115,28 @@ struct WordField: View {
var palette: Palette?
var body: some View {
TextField(placeholder, text: $value)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(border, lineWidth: mark == nil ? 0 : 1.5)
)
HStack(spacing: 8) {
TextField(placeholder, text: $value)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if let mark {
Image(systemName: mark ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(mark ? (palette?.success ?? .green) : (palette?.error ?? .red))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(border, lineWidth: mark == nil ? 0.5 : 1.5)
)
}
private var border: Color {
if mark == true { return palette?.success ?? .green }
if mark == false { return palette?.error ?? .red }
return .clear
return Color(.separator)
}
}
@ -158,10 +173,29 @@ struct WordChip: View {
var body: some View {
Button(text, action: action)
.buttonStyle(.bordered)
.tint(.primary)
.controlSize(.small)
}
}
/// Drop zone for word ordering: visible even when empty.
struct WordSlot<Content: View>: View {
let empty: Bool
@ViewBuilder var content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(8)
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(style: StrokeStyle(lineWidth: empty ? 1 : 0, dash: [5, 4]))
.foregroundStyle(.tertiary)
)
}
}
struct OptionChip: View {
let text: String
let selected: Bool
@ -171,15 +205,32 @@ struct OptionChip: View {
var body: some View {
Button(action: action) {
HStack {
Text(text).frame(maxWidth: .infinity, alignment: .leading)
if selected { Image(systemName: "checkmark") }
Image(systemName: symbol)
.foregroundStyle(symbolColor)
Text(text)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 4)
}
.buttonStyle(.bordered)
.tint(tint)
.padding(.bottom, 4)
}
private var symbol: String {
if mark == true { return "checkmark.circle.fill" }
if mark == false { return "xmark.circle.fill" }
return selected ? "largecircle.fill.circle" : "circle"
}
private var symbolColor: Color {
if mark == true { return .green }
if mark == false { return .red }
return selected ? .accentColor : .secondary
}
private var tint: Color {
if mark == true { return .green }
if mark == false { return .red }
@ -196,6 +247,39 @@ extension View {
self
}
}
/// Detail screens with their own bottom action hide the tab bar so the
/// primary button is never covered by it.
func detailScreen(_ title: String, subtitle: String? = nil) -> some View {
navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(subtitle)
.toolbar(.hidden, for: .tabBar)
}
/// Full-width, leading-aligned content for exercise screens.
func exerciseContent() -> some View {
frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
/// Bottom action area used by exercises and lesson slides.
struct BottomAction<Extra: View>: View {
let label: String
var action: () -> Void
@ViewBuilder var extra: () -> Extra
var body: some View {
VStack(spacing: 10) {
extra()
PrimaryButton(label: label, action: action)
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
.background(.bar)
}
}
func subjectSymbol(_ icon: String) -> String {
@ -207,3 +291,24 @@ func subjectSymbol(_ icon: String) -> String {
default: return "lock.fill"
}
}
/// Subject glyph for list rows: real flags for the languages, SF Symbols otherwise.
struct SubjectIcon: View {
let icon: String
var open = true
var body: some View {
Group {
switch icon {
case "de": GermanFlag(size: 26)
case "cz": CzechFlag(size: 26)
default:
Image(systemName: subjectSymbol(icon))
.font(.title3)
.foregroundStyle(open ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary))
}
}
.frame(width: 28, height: 28)
.opacity(open ? 1 : 0.6)
}
}

View file

@ -14,27 +14,19 @@ struct ExerciseScaffold<Content: View>: View {
let p = vm.palette
ScrollView {
VStack(alignment: .leading, spacing: 8) { content() }
.padding()
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(sub)
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button(action, action: onAction)
.buttonStyle(.borderedProminent)
}
.exerciseContent()
}
.scrollDismissesKeyboard(.interactively)
.detailScreen(title, subtitle: sub)
.safeAreaInset(edge: .bottom) {
if !feedback.isEmpty {
FeedbackLine(text: feedback, kind: kind, palette: p)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.padding(.horizontal)
.padding(.bottom, 4)
BottomAction(label: action, action: onAction) {
if !feedback.isEmpty {
FeedbackLine(text: feedback, kind: kind, palette: p)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.animation(.default, value: feedback)
}
}
@ -102,7 +94,7 @@ struct DispatchExercise: View {
case "letters", "letters_gap": LettersEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "table": TableEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "reveal": RevealEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
default: Text("?").foregroundStyle(vm.palette.text)
default: Text("?").foregroundStyle(.primary)
}
}
}
@ -139,8 +131,7 @@ private struct ChoiceEx: View {
}, feedback: fb.0, kind: fb.1) {
ForEach(qs.indices, id: \.self) { i in
Text("\(i + 1). \(qs[i].str("prompt"))")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.text)
.font(.body.weight(.semibold))
.padding(.bottom, 6)
ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in
let sel = picks[i] == o
@ -196,20 +187,18 @@ private struct AssemblyEx: View {
}, feedback: fb.0, kind: fb.1) {
ForEach(items.indices, id: \.self) { i in
if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt) }
FlowLayout {
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) }
ForEach(placed[i], id: \.self) { idx in
if idx < items[i].strs("words").count {
WordChip(text: items[i].strs("words")[idx]) {
placed[i].removeAll { $0 == idx }
pools[i].append(idx)
WordSlot(empty: placed[i].isEmpty) {
FlowLayout {
ForEach(placed[i], id: \.self) { idx in
if idx < items[i].strs("words").count {
WordChip(text: items[i].strs("words")[idx]) {
placed[i].removeAll { $0 == idx }
pools[i].append(idx)
}
}
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.bottom, 6)
FlowLayout(spacing: 6) {
ForEach(pools[i], id: \.self) { idx in
@ -395,7 +384,7 @@ private struct AssignEx: View {
SegChip(label: groups[i], selected: active == i) { active = i }
}
}
Text(vm.tr("wordbank")).font(.system(size: 12)).foregroundStyle(p.subtext).padding(.top, 10)
Text(vm.tr("wordbank")).font(.caption).foregroundStyle(.secondary).padding(.top, 10)
FlowLayout(spacing: 6) {
ForEach(items.indices, id: \.self) { i in
if loc[i] == -1 {
@ -405,7 +394,7 @@ private struct AssignEx: View {
}
}
ForEach(groups.indices, id: \.self) { g in
Text(groups[g]).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 10)
Text(groups[g]).font(.caption.weight(.semibold)).foregroundStyle(.secondary).padding(.top, 10)
FlowLayout(spacing: 6) {
ForEach(items.indices, id: \.self) { i in
if loc[i] == g {
@ -451,7 +440,8 @@ private struct HangmanEx: View {
let letters = spec.strs("letters")
let p = vm.palette
let cur = word < words.count ? words[word].uppercased() : ""
VStack(alignment: .leading, spacing: 8) {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(.secondary)
if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))") }
Canvas { ctx, size in
@ -461,30 +451,30 @@ private struct HangmanEx: View {
gallows.move(to: CGPoint(x: 70, y: size.height - 10))
gallows.addLine(to: CGPoint(x: 70, y: 16))
gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, y: 16))
ctx.stroke(gallows, with: .color(p.text), style: StrokeStyle(lineWidth: 6, lineCap: .round))
ctx.stroke(gallows, with: .color(.primary), style: StrokeStyle(lineWidth: 6, lineCap: .round))
let x = size.width * 0.3 + 40
if misses > 0 {
var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36))
ctx.stroke(rope, with: .color(p.text), lineWidth: 4)
ctx.stroke(rope, with: .color(.primary), lineWidth: 4)
}
if misses > 1 {
ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(p.text), lineWidth: 4)
ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(.primary), lineWidth: 4)
}
if misses > 2 {
var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110))
ctx.stroke(body, with: .color(p.text), lineWidth: 4)
ctx.stroke(body, with: .color(.primary), lineWidth: 4)
}
if misses > 3 {
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100))
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
ctx.stroke(a, with: .color(.primary), lineWidth: 4)
}
if misses > 4 {
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100))
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
ctx.stroke(a, with: .color(.primary), lineWidth: 4)
}
if misses > 5 {
var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140))
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
ctx.stroke(a, with: .color(.primary), lineWidth: 4)
}
}
.frame(height: 160)
@ -493,13 +483,14 @@ private struct HangmanEx: View {
let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
let show = idx.map { guessed[$0] } ?? false
Text(show ? String(ch) : "_")
.font(.system(size: 26, weight: .bold))
.foregroundStyle(p.text)
.font(.system(size: 26, weight: .bold, design: .rounded))
.monospaced()
}
}
ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in
HStack(spacing: 4) {
ForEach(row, id: \.self) { letter in
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
FlowLayout(spacing: 6) {
ForEach(letters, id: \.self) { letter in
if let i = letters.firstIndex(of: letter) {
Button {
guessed[i] = true
@ -523,17 +514,14 @@ private struct HangmanEx: View {
}
} label: {
Text(letter)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(p.text)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(guessed[i] ? p.surface1 : p.mantle, in: RoundedRectangle(cornerRadius: 8))
.font(.body.weight(.semibold))
.frame(minWidth: 22)
}
.buttonStyle(.plain)
.buttonStyle(.bordered)
.tint(guessed[i] ? .secondary : .primary)
.disabled(guessed[i] || finished)
}
}
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
if misses >= 6 && !finished {
@ -543,12 +531,10 @@ private struct HangmanEx: View {
fb = ("", "")
}
}
Spacer()
}
.padding()
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(sub)
.exerciseContent()
}
.detailScreen(title, subtitle: sub)
}
}
@ -586,40 +572,63 @@ private struct VocabEx: View {
var body: some View {
let p = vm.palette
VStack(alignment: .leading, spacing: 8) {
Text(vm.fmt("trans_progress", done, total)).foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ProgressView(value: Double(done), total: Double(max(total, 1)))
Text(vm.fmt("trans_progress", done, total)).font(.footnote).foregroundStyle(.secondary)
if cards.isEmpty {
Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success)
Label(vm.tr("trans_done"), systemImage: "checkmark.seal.fill")
.font(.headline)
.foregroundStyle(p.success)
.padding(.top, 8)
} else if index < cards.count {
let cur = cards[index]
Hint(text: cur.0)
Prompt(text: cur.1)
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) }
WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p)
PrimaryButton(label: vm.tr("check")) {
let norm = normalizeAnswer(value)
if answerAccepts(norm, cur.2) {
done += 1
cards.remove(at: index)
if cards.isEmpty { fb = (vm.tr("trans_done"), "ok"); onDone() }
else { if index >= cards.count { index = 0 }; value = ""; fb = ("", "") }
} else if answerIncomplete(norm, cur.2) {
fb = (vm.tr("trans_incomplete"), "warn")
} else {
fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err")
let item = cards.remove(at: index)
cards.append(item)
if index >= cards.count { index = 0 }
value = ""
GlassCard {
VStack(alignment: .leading, spacing: 6) {
Text(cur.0).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Text(cur.1).font(.title2.weight(.semibold))
}
}
.padding(.vertical, 8)
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) }
WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p)
.submitLabel(.done)
.onSubmit { check(cur) }
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
}
Spacer()
}
.padding()
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(sub)
.exerciseContent()
}
.scrollDismissesKeyboard(.interactively)
.detailScreen(title, subtitle: sub)
.safeAreaInset(edge: .bottom) {
if index < cards.count {
BottomAction(label: vm.tr("check"), action: { check(cards[index]) }) { EmptyView() }
}
}
}
private func check(_ cur: (String, String, String)) {
let norm = normalizeAnswer(value)
if answerAccepts(norm, cur.2) {
done += 1
cards.remove(at: index)
if cards.isEmpty {
fb = (vm.tr("trans_done"), "ok")
onDone()
} else {
if index >= cards.count { index = 0 }
value = ""
fb = ("", "")
}
} else if answerIncomplete(norm, cur.2) {
fb = (vm.tr("trans_incomplete"), "warn")
} else {
fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err")
let item = cards.remove(at: index)
cards.append(item)
if index >= cards.count { index = 0 }
value = ""
}
}
}

View file

@ -16,8 +16,8 @@ struct DialogEx: View {
let line = lines[i]
if let name = line.name {
Text(name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(vm.palette.subtext)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.vertical, 6)
}
ComboLine(
@ -68,7 +68,7 @@ struct ComboEx: View {
ComboRows(vm: vm, title: title, sub: sub, pool: spec.strs("pool"), n: allRows.count, onDone: onDone, revealMean: spec.bool("transUpfront")) { picks, expected, revealed in
ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in
if let title = line.title {
Text(title).font(.system(size: 15, weight: .semibold)).foregroundStyle(vm.palette.subtext)
Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(.secondary)
}
ComboLine(
vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx,
@ -92,18 +92,18 @@ struct ComboEx: View {
ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
FlowLayout(spacing: 4) {
if let num = block.num {
Text(num).font(.system(size: 16, weight: .semibold)).foregroundStyle(vm.palette.text)
Text(num).font(.body.weight(.semibold)).foregroundStyle(.primary)
}
ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(vm.palette.text)
Text(s).foregroundStyle(.primary)
case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
}
}
if let mean = block.meaning {
Text(vm.tr(mean)).font(.system(size: 12)).foregroundStyle(vm.palette.subtext)
Text(vm.tr(mean)).font(.caption).foregroundStyle(.secondary)
}
}
.padding(.bottom, 6)
@ -267,35 +267,36 @@ private struct ComboLine: View {
let chip = Button {
open = true
} label: {
Text(pick.isEmpty ? "—" : pick)
.font(.system(size: 15))
.foregroundStyle(pick.isEmpty ? p.overlay : p.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(p.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(mark == true ? p.success : mark == false ? p.error : p.text.opacity(0.12), lineWidth: 1)
)
HStack(spacing: 4) {
Text(pick.isEmpty ? "…" : pick)
.foregroundStyle(pick.isEmpty ? Color.secondary : Color.primary)
.frame(minWidth: 36)
Image(systemName: mark == true ? "checkmark.circle.fill" : mark == false ? "xmark.circle.fill" : "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(mark == true ? p.success : mark == false ? p.error : Color.secondary)
}
}
.buttonStyle(.plain)
.buttonStyle(.bordered)
.controlSize(.small)
.tint(mark == true ? p.success : mark == false ? p.error : .secondary)
Group {
if inline {
chip
} else {
FlowLayout(spacing: 6) {
if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) }
if !prefix.isEmpty { Text(prefix) }
chip
if !suffix.isEmpty { Text(suffix).font(.system(size: 16)).foregroundStyle(p.text) }
if !suffix.isEmpty { Text(suffix) }
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 8)
}
}
.onAppear {
if idx >= 0 && idx < expected.count { expected[idx] = answer }
}
.onChange(of: answer) { new in
.onChange(of: answer) { _, new in
if idx >= 0 && idx < expected.count { expected[idx] = new }
}
.sheet(isPresented: $open) {
@ -319,16 +320,22 @@ struct WordPicker: View {
var body: some View {
NavigationStack {
List(Array(pool.enumerated()), id: \.offset) { _, w in
Button(w) { onPick(w) }
Button {
onPick(w)
} label: {
Text(w).foregroundStyle(.primary)
}
}
.navigationTitle("")
.navigationTitle("Vyberte slovo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Zavřít", role: .cancel) { onPick(nil) }
Button(role: .cancel) { onPick(nil) } label: { Image(systemName: "xmark") }
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
@ -351,12 +358,12 @@ struct Ex2Ex: View {
ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in
FlowLayout(spacing: 4) {
if let num = row.num {
Text(num).foregroundStyle(vm.palette.text).padding(.trailing, 6)
Text(num).foregroundStyle(.primary).padding(.trailing, 6)
}
ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(vm.palette.text)
Text(s).foregroundStyle(.primary)
case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
}
@ -415,7 +422,6 @@ struct LettersEx: View {
var body: some View {
let rows = spec.arr("rows")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
var i = 0, ok = 0, total = 0
for r in rows {
@ -449,12 +455,12 @@ private struct LettersRows: View {
VStack(alignment: .leading, spacing: 8) {
ForEach(pairs.indices, id: \.self) { ri in
let row = pairs[ri]
if let num = row.num { Text(num).foregroundStyle(p.subtext) }
if let num = row.num { Text(num).foregroundStyle(.secondary) }
FlowLayout {
ForEach(row.parts.indices, id: \.self) { pi in
switch row.parts[pi] {
case .text(let s):
Text(s).foregroundStyle(p.text)
Text(s).foregroundStyle(.primary)
case .field(let idx, let ans):
WordField(
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
@ -539,10 +545,10 @@ struct TableEx: View {
revealed = true
}, feedback: fb.0, kind: fb.1) {
ForEach(nouns.indices, id: \.self) { r in
Text(nouns[r]).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(nouns[r]).font(.body.weight(.bold)).foregroundStyle(.primary)
ForEach(persons.indices, id: \.self) { c in
let i = r * persons.count + c
Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext)
Text(persons[c]).font(.caption).foregroundStyle(.secondary)
WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), placeholder: "", palette: p)
}
Spacer().frame(height: 8)
@ -658,29 +664,41 @@ private struct VlsmEx: View {
let tasks = vm.content.netTasks
let answers = vm.content.netAnswers
let p = vm.palette
VStack(alignment: .leading, spacing: 0) {
ScrollView {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(tasks.indices, id: \.self) { ti in
let task = tasks[ti]
let block = ti < answers.count ? answers[ti] : []
let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 }
Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8)
GlassCard {
Label {
Text(task.str("prompt"))
} icon: {
Image(systemName: done[ti] ? "checkmark.circle.fill" : "\(ti + 1).circle")
.foregroundStyle(done[ti] ? p.success : .secondary)
}
}
.padding(.vertical, 4)
if infeasible {
Hint(text: vm.tr("net_infeasible"))
} else {
ForEach(block.indices, id: \.self) { si in
Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(p.subtext)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.top, 6)
PrefixMenu(value: Binding(
get: { rows[ti][si].pfx },
set: { rows[ti][si].pfx = $0 }
), palette: p)
WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), placeholder: "síť", palette: p)
.keyboardType(.decimalPad)
WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), placeholder: "broadcast", palette: p)
.keyboardType(.decimalPad)
WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), placeholder: "první uzel", palette: p)
.keyboardType(.decimalPad)
WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), placeholder: "poslední uzel", palette: p)
.keyboardType(.decimalPad)
}
PillButton(label: vm.tr("check")) {
let ok = block.indices.allSatisfy { si in
@ -717,14 +735,22 @@ private struct VlsmEx: View {
maybeFinish()
}
if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) }
Divider().padding(.vertical, 8)
}
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
.exerciseContent()
}
.padding()
.navigationTitle(vm.tr("net_ex_title"))
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
.detailScreen(vm.tr("net_ex_title"))
.safeAreaInset(edge: .bottom) {
if !fb.0.isEmpty {
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.bar)
}
}
.animation(.default, value: fb.0)
}
private func maybeFinish() {
@ -739,15 +765,17 @@ private struct PrefixMenu: View {
var body: some View {
Button { open = true } label: {
Text(value.isEmpty ? "/…" : value)
.font(.system(size: 15))
.foregroundStyle(palette.text)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
HStack {
Text("Prefix").foregroundStyle(.secondary)
Spacer()
Text(value.isEmpty ? "/…" : value)
.foregroundStyle(value.isEmpty ? Color.secondary : Color.primary)
.monospacedDigit()
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.padding(.vertical, 4)
.buttonStyle(.bordered)
.tint(.secondary)
.sheet(isPresented: $open) {
WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
if let word { value = word }
@ -765,63 +793,75 @@ struct LitQuizScreen: View {
@State private var score = 0
@State private var answered = false
@State private var finished = false
@State private var picked = -1
var body: some View {
if let book = vm.content.book(id) {
let qs = book.arr("quiz")
let p = vm.palette
let kahoot = [p.accent, p.error, p.warning, p.success]
VStack(alignment: .leading, spacing: 8) {
if finished {
Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext)
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
PrimaryButton(label: vm.tr("lit_again")) {
idx = 0; score = 0; answered = false; finished = false
}
} else if idx < qs.count {
let q = qs[idx]
HStack {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)).foregroundStyle(p.subtext)
Spacer()
Text(vm.fmt("lit_score_fmt", score, qs.count)).foregroundStyle(p.subtext)
}
Text(q.str("prompt")).font(.system(size: 18, weight: .semibold)).foregroundStyle(p.text).padding(.vertical, 12)
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
Button {
answered = true
if i == q.int("correct") { score += 1 }
} label: {
Text(opt)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.onAccent)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(kahoot[i % 4], in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
.disabled(answered)
.padding(.bottom, 8)
}
if answered {
Meaning(text: q.str("expl"), palette: p, visible: true)
PrimaryButton(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next")) {
if idx + 1 >= qs.count {
finished = true
vm.markBookQuiz(id)
} else {
idx += 1
answered = false
ScrollView {
VStack(alignment: .leading, spacing: 8) {
if finished {
GlassCard {
VStack(alignment: .leading, spacing: 8) {
Label(vm.tr("lit_finished"), systemImage: "rosette")
.font(.title2.weight(.bold))
Text(vm.tr("lit_finished_text")).foregroundStyle(.secondary)
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.headline)
}
}
} else if idx < qs.count {
let q = qs[idx]
ProgressView(value: Double(idx), total: Double(qs.count))
HStack {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count))
Spacer()
Text(vm.fmt("lit_score_fmt", score, qs.count))
}
.font(.footnote)
.foregroundStyle(.secondary)
Text(q.str("prompt")).font(.title3.weight(.semibold)).padding(.vertical, 12)
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
let correct = i == q.int("correct")
let chosen = answered && picked == i
OptionChip(
text: opt,
selected: chosen,
mark: answered ? (correct ? true : (chosen ? false : nil)) : nil
) {
guard !answered else { return }
picked = i
answered = true
if correct { score += 1 }
}
}
if answered {
Meaning(text: q.str("expl"), palette: p, visible: true)
}
}
}
Spacer()
.exerciseContent()
}
.padding()
.navigationTitle(vm.tr(book.str("quizTitle")))
.navigationBarTitleDisplayMode(.inline)
.navigationSubtitle(vm.tr(book.str("quizSub")))
.detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
.safeAreaInset(edge: .bottom) {
if finished {
BottomAction(label: vm.tr("lit_again"), action: {
idx = 0; score = 0; answered = false; finished = false; picked = -1
}) { EmptyView() }
} else if answered {
BottomAction(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), action: {
if idx + 1 >= qs.count {
finished = true
vm.markBookQuiz(id)
} else {
idx += 1
answered = false
picked = -1
}
}) { EmptyView() }
}
}
.animation(.default, value: answered)
}
}
}

View file

@ -1,166 +1,6 @@
import SwiftUI
struct BackIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.18))
p.addLine(to: CGPoint(x: sz.width * 0.28, y: sz.height * 0.5))
p.addLine(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.82))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct SearchIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.28
let c = CGPoint(x: sz.width * 0.42, y: sz.height * 0.42)
ctx.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.12)
var handle = Path()
handle.move(to: CGPoint(x: sz.width * 0.62, y: sz.height * 0.62))
handle.addLine(to: CGPoint(x: sz.width * 0.82, y: sz.height * 0.82))
ctx.stroke(handle, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.12, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct StatsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let w = min(sz.width, sz.height) * 0.16
let bars: [(CGFloat, CGFloat, CGFloat)] = [
(0.18, 0.48, 0.36), (0.42, 0.22, 0.62), (0.66, 0.36, 0.48),
]
for b in bars {
let rect = CGRect(x: sz.width * b.0, y: sz.height * b.1, width: w, height: sz.height * b.2)
ctx.fill(Path(roundedRect: rect, cornerRadius: w), with: .color(color))
}
}
.frame(width: size, height: size)
}
}
struct SettingsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let cx = sz.width / 2
let cy = sz.height / 2
let s = min(sz.width, sz.height)
let rOuter = s * 0.48
let rInner = s * 0.34
let rHole = s * 0.16
let teeth = 8
let toothHalf = CGFloat.pi / CGFloat(teeth)
var gear = Path()
for i in 0..<teeth {
let a = -CGFloat.pi / 2 + 2 * CGFloat.pi * CGFloat(i) / CGFloat(teeth)
let a0 = a - toothHalf * 0.55
let a1 = a - toothHalf * 0.28
let a2 = a + toothHalf * 0.28
let a3 = a + toothHalf * 0.55
let p0 = CGPoint(x: cx + cos(a0) * rInner, y: cy + sin(a0) * rInner)
if i == 0 { gear.move(to: p0) } else { gear.addLine(to: p0) }
gear.addLine(to: CGPoint(x: cx + cos(a1) * rOuter, y: cy + sin(a1) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a2) * rOuter, y: cy + sin(a2) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a3) * rInner, y: cy + sin(a3) * rInner))
}
gear.closeSubpath()
gear.addEllipse(in: CGRect(x: cx - rHole, y: cy - rHole, width: rHole * 2, height: rHole * 2))
ctx.fill(gear, with: .color(color), style: FillStyle(eoFill: true))
}
.frame(width: size, height: size)
}
}
struct LockIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let body = CGRect(x: sz.width * 0.28, y: sz.height * 0.46, width: sz.width * 0.44, height: sz.height * 0.36)
ctx.fill(Path(roundedRect: body, cornerRadius: min(sz.width, sz.height) * 0.08), with: .color(color))
var arc = Path()
arc.addArc(
center: CGPoint(x: sz.width * 0.5, y: sz.height * 0.38),
radius: sz.width * 0.18,
startAngle: .degrees(200),
endAngle: .degrees(340),
clockwise: false
)
ctx.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.1, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct CheckIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.18, y: sz.height * 0.52))
p.addLine(to: CGPoint(x: sz.width * 0.4, y: sz.height * 0.74))
p.addLine(to: CGPoint(x: sz.width * 0.84, y: sz.height * 0.26))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct WifiIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let c = CGPoint(x: sz.width / 2, y: sz.height * 0.72)
ctx.fill(Path(ellipseIn: CGRect(x: c.x - sz.width * 0.06, y: c.y - sz.width * 0.06, width: sz.width * 0.12, height: sz.width * 0.12)), with: .color(color))
for i in 1...3 {
let r = min(sz.width, sz.height) * (0.16 + CGFloat(i) * 0.16)
var p = Path()
p.addArc(center: c, radius: r, startAngle: .degrees(210), endAngle: .degrees(330), clockwise: false)
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct ChipIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let m = min(sz.width, sz.height) * 0.22
ctx.stroke(
Path(roundedRect: CGRect(x: m, y: m, width: sz.width - 2 * m, height: sz.height - 2 * m), cornerRadius: 6),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
let pin = min(sz.width, sz.height) * 0.08
for i in 0..<3 {
let t = m + (sz.height - 2 * m) * CGFloat(i + 1) / 4
var l = Path(); l.move(to: CGPoint(x: m - pin, y: t)); l.addLine(to: CGPoint(x: m, y: t))
var r = Path(); r.move(to: CGPoint(x: sz.width - m, y: t)); r.addLine(to: CGPoint(x: sz.width - m + pin, y: t))
ctx.stroke(l, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
ctx.stroke(r, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
}
.frame(width: size, height: size)
}
}
// Circular flags used as subject glyphs; everything else is an SF Symbol.
struct GermanFlag: View {
var size: CGFloat = 48
@ -193,112 +33,3 @@ struct CzechFlag: View {
.frame(width: size, height: size)
}
}
struct BookIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
ctx.stroke(
Path(roundedRect: CGRect(x: sz.width * 0.22, y: sz.height * 0.18, width: sz.width * 0.56, height: sz.height * 0.64), cornerRadius: 4),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
var spine = Path()
spine.move(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.18))
spine.addLine(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.82))
ctx.stroke(spine, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
}
.frame(width: size, height: size)
}
}
struct QuizIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.36
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
let d = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width / 2 - d / 2, y: sz.height * 0.68 - d / 2, width: d, height: d)), with: .color(color))
var stem = Path()
stem.move(to: CGPoint(x: sz.width / 2, y: sz.height * 0.32))
stem.addLine(to: CGPoint(x: sz.width / 2, y: sz.height * 0.52))
ctx.stroke(stem, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct OrderIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
for i in 0..<3 {
let y = sz.height * (0.28 + CGFloat(i) * 0.22)
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.22, y: y))
p.addLine(to: CGPoint(x: sz.width * 0.78, y: y))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct GlobeIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.34
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width * 0.32, y: sz.height * 0.18, width: sz.width * 0.36, height: sz.height * 0.64)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
.frame(width: size, height: size)
}
}
struct PeopleIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.35 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.65 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
var a = Path(); a.addArc(center: CGPoint(x: sz.width * 0.35, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
var b = Path(); b.addArc(center: CGPoint(x: sz.width * 0.65, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
ctx.stroke(a, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
ctx.stroke(b, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct BulbIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.22
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height * 0.4 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.fill(Path(roundedRect: CGRect(x: sz.width * 0.4, y: sz.height * 0.58, width: sz.width * 0.2, height: sz.height * 0.2), cornerRadius: 3), with: .color(color))
}
.frame(width: size, height: size)
}
}
@ViewBuilder
func noteIcon(_ name: String, _ color: Color) -> some View {
switch name {
case "globe": GlobeIcon(color: color)
case "people": PeopleIcon(color: color)
case "bulb": BulbIcon(color: color)
case "quiz": QuizIcon(color: color)
case "order": OrderIcon(color: color)
default: BookIcon(color: color)
}
}

View file

@ -1,11 +0,0 @@
import SwiftUI
struct MapNode {
let id: String
let label: String
let locked: Bool
let done: Bool
let current: Bool
var finish: Bool = false
var destination: Route?
}

View file

@ -54,7 +54,7 @@ struct PracticeHome: View {
}
}
} icon: {
Image(systemName: subjectSymbol(s.str("icon")))
SubjectIcon(icon: s.str("icon"), open: s.bool("open"))
}
}
}
@ -77,11 +77,11 @@ struct SettingsScreen: View {
vm.setTheme(id)
} label: {
HStack {
Circle().fill(themePalette(id, vm.mode).accent).frame(width: 14, height: 14)
Text(themeNames[id.rawValue])
Circle().fill(themePalette(id, vm.mode).tint).frame(width: 14, height: 14)
Text(themeNames[id.rawValue]).foregroundStyle(.primary)
Spacer()
if vm.themeId == id {
Image(systemName: "checkmark")
Image(systemName: "checkmark").fontWeight(.semibold)
}
}
}
@ -130,28 +130,42 @@ struct StatsScreen: View {
let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
List {
Section {
HStack {
stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
GlassCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
}
ProgressView(value: pct)
}
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
ProgressView(value: pct)
.listRowSeparator(.hidden)
}
Section(vm.tr("stats_section")) {
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
let open = s.bool("open")
let part = subjectSum(vm, s)
VStack(alignment: .leading, spacing: 6) {
Text(vm.tr(s.str("key")))
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
.font(.caption)
.foregroundStyle(.secondary)
if open && part.totalEx > 0 {
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
Label {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(vm.tr(s.str("key")))
Spacer()
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
if open && part.totalEx > 0 {
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
}
}
} icon: {
SubjectIcon(icon: s.str("icon"), open: open)
}
.opacity(open ? 1 : 0.5)
.foregroundStyle(open ? .primary : .secondary)
}
}
}
@ -159,9 +173,9 @@ struct StatsScreen: View {
}
private func stat(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.caption).foregroundStyle(.secondary)
Text(value).font(.title3.bold())
Text(value).font(.title3.bold()).monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ -397,29 +411,59 @@ struct SlidesScreen: View {
.foregroundStyle(.secondary)
Text(slide.str("title")).font(.title2.bold())
if let tip = slide.strOrNull("tip") {
GlassCard { Text(tip) }
GlassCard {
Label(tip, systemImage: "lightbulb.fill")
}
}
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
Text(line).padding(.vertical, 4)
}
}
.padding()
.padding(.bottom, 24)
.frame(maxWidth: .infinity, alignment: .leading)
}
.tag(i)
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.navigationTitle(title)
.navigationSubtitle(subtitle)
.toolbar {
ToolbarItem(placement: .bottomBar) {
if idx < slides.count - 1 {
Button(vm.tr("net_slide_next")) { idx += 1 }
} else {
NavigationLink(vm.tr("net_slide_start"), value: next)
.tabViewStyle(.page(indexDisplayMode: .never))
.detailScreen(title, subtitle: subtitle)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 10) {
HStack(spacing: 6) {
ForEach(slides.indices, id: \.self) { i in
Capsule()
.fill(i == idx ? Color.accentColor : Color(.tertiarySystemFill))
.frame(width: i == idx ? 18 : 6, height: 6)
}
}
.animation(.default, value: idx)
HStack(spacing: 10) {
if idx > 0 {
Button {
withAnimation { idx -= 1 }
} label: {
Image(systemName: "chevron.left")
.frame(minWidth: 24)
}
.buttonStyle(.glass)
.controlSize(.large)
}
if idx < slides.count - 1 {
PrimaryButton(label: vm.tr("net_slide_next")) {
withAnimation { idx += 1 }
}
} else {
PrimaryButton(label: vm.tr("net_slide_start")) {
vm.go(next)
}
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
.background(.bar)
}
}
}

View file

@ -225,6 +225,8 @@ const TrEntry tr_ui[] = {
{"stats", "Statistiky", "Statistics"},
{"stats_title", "Statistiky", "Statistics"},
{"stats_sub", "Přehled vašeho pokroku.", "Overview of your progress."},
{"mluv_sub", "Cvičení z mluvnice – styl maturita / přijímačky z ČJL",
"Grammar exercises in the style of the maturita and entrance exams"},
{"stats_ex_label", "Cvičení", "Exercises"},
{"stats_pct_label", "Hotovo", "Done"},
{"stats_units_label", "Jednotky", "Units"},

View file

@ -61,8 +61,7 @@ void mluvnice_init(void) {
mluv_unit.title = "Mluvnice";
mluv_unit.page = "mluvnice";
mluv_unit.ex_tag = "mluv";
mluv_unit.sub_key = "Cvičení z mluvnice – styl maturita / "
"přijímačky z ČJL";
mluv_unit.sub_key = "mluv_sub";
mluv_unit.back_target = "czechmap";
mluv_unit.progress_file = app_progress_mluvnice;
mluv_unit.n_ex = MLUV_N_EX;

View file

@ -2669,7 +2669,10 @@ GtkWidget *net_qz_make_entry(void) {
GtkWidget *e = gtk_entry_new();
gtk_widget_set_hexpand(e, TRUE);
gtk_editable_set_width_chars(GTK_EDITABLE(e), 15);
/* Four address columns must share the window; 15 chars each pushed the
* last column past the right edge. */
gtk_editable_set_width_chars(GTK_EDITABLE(e), 7);
gtk_editable_set_max_width_chars(GTK_EDITABLE(e), 16);
return e;
}
@ -2688,8 +2691,10 @@ GtkWidget *build_net_exercise_page(void) {
top_bar("netunit1", "net_ex_title", NULL));
scroll = gtk_scrolled_window_new();
/* Never scroll sideways: the address table must shrink to the window
* instead of pushing its last column off screen. */
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll),
GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC);
GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
gtk_widget_set_margin_top(scroll, 12);
gtk_box_append(GTK_BOX(page), scroll);
@ -2732,6 +2737,7 @@ GtkWidget *build_net_exercise_page(void) {
gtk_grid_set_row_spacing(GTK_GRID(table), 6);
gtk_grid_set_column_spacing(GTK_GRID(table), 8);
gtk_widget_set_margin_top(table, 6);
gtk_widget_set_hexpand(table, TRUE);
gtk_box_append(GTK_BOX(task), table);
for (guint c = 1; c < 6; c++) {

View file

@ -299,9 +299,11 @@ GtkWidget *build_stats_page(void) {
outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(outer, "stats-page");
/* Same header rhythm as the other pages (top_bar). */
top = gtk_center_box_new();
gtk_widget_set_margin_top(top, 2);
gtk_widget_set_margin_bottom(top, 6);
gtk_widget_add_css_class(top, "page-top");
gtk_widget_set_margin_top(top, 8);
gtk_widget_set_margin_bottom(top, 18);
gtk_box_append(GTK_BOX(outer), top);
back = make_back_button("subjects");