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" applicationId = "org.maturita.maturita"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 5 versionCode = 6
versionName = "android-5" versionName = "android-6"
buildConfigField("String", "COMMIT", "\"$commit\"") buildConfigField("String", "COMMIT", "\"$commit\"")
buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"") buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"")
buildConfigField("String", "UPDATE_BRANCH", "\"builds\"") buildConfigField("String", "UPDATE_BRANCH", "\"builds\"")

View file

@ -26,7 +26,6 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
private set private set
var lang by mutableStateOf(progress.lang) var lang by mutableStateOf(progress.lang)
private set private set
var settingsOpen by mutableStateOf(false)
var searchOpen by mutableStateOf(false) var searchOpen by mutableStateOf(false)
var searchQuery by mutableStateOf("") var searchQuery by mutableStateOf("")
var tick by mutableStateOf(0) var tick by mutableStateOf(0)
@ -34,19 +33,32 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
var update by mutableStateOf(UpdateState()) var update by mutableStateOf(UpdateState())
private set private set
private val stack = mutableStateListOf<Route>(Route.Welcome) private val stack = mutableStateListOf<Route>(Route.Subjects)
val route: Route get() = stack.last() 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) val palette get() = themePalette(themeId, mode)
fun tr(key: String?) = content.tr(key, lang) fun tr(key: String?) = content.tr(key, lang)
fun fmt(key: String, vararg args: Any) = content.fmt(key, lang, *args) 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) { fun go(r: Route) {
if (stack.last() == r) return
stack.add(r)
searchOpen = false 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?) { fun goPage(page: String?) {
@ -54,8 +66,25 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
go(r) 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) 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) { fun setTheme(id: ThemeId) {

View file

@ -12,56 +12,39 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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.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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import org.maturita.maturita.data.ColorMode
import androidx.compose.ui.unit.dp import org.maturita.maturita.data.Palette
import androidx.compose.ui.unit.sp
import org.maturita.maturita.ui.BookListScreen import org.maturita.maturita.ui.BookListScreen
import org.maturita.maturita.ui.BookScreen import org.maturita.maturita.ui.BookScreen
import org.maturita.maturita.ui.CzechMapScreen import org.maturita.maturita.ui.CzechMapScreen
import org.maturita.maturita.ui.GermanExercise import org.maturita.maturita.ui.GermanExercise
import org.maturita.maturita.ui.HomeScreen
import org.maturita.maturita.ui.HwQuizScreen import org.maturita.maturita.ui.HwQuizScreen
import org.maturita.maturita.ui.IconHit import org.maturita.maturita.ui.LessonListScreen
import org.maturita.maturita.ui.LessonMapScreen import org.maturita.maturita.ui.LessonRow
import org.maturita.maturita.ui.LitQuizScreen import org.maturita.maturita.ui.LitQuizScreen
import org.maturita.maturita.ui.MapNode
import org.maturita.maturita.ui.MluvExercise import org.maturita.maturita.ui.MluvExercise
import org.maturita.maturita.ui.NetQuizScreen import org.maturita.maturita.ui.NetQuizScreen
import org.maturita.maturita.ui.NetYearsScreen import org.maturita.maturita.ui.NetYearsScreen
import org.maturita.maturita.ui.PlotScreen import org.maturita.maturita.ui.PlotScreen
import org.maturita.maturita.ui.RoadmapScreen import org.maturita.maturita.ui.RoadmapScreen
import org.maturita.maturita.ui.SearchIcon import org.maturita.maturita.ui.SearchScreen
import org.maturita.maturita.ui.SearchOverlay import org.maturita.maturita.ui.SettingsScreen
import org.maturita.maturita.ui.SettingsIcon
import org.maturita.maturita.ui.SettingsSheet
import org.maturita.maturita.ui.SlidesScreen 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.StatsScreen
import org.maturita.maturita.ui.SubjectsScreen
import org.maturita.maturita.ui.UnitMapScreen import org.maturita.maturita.ui.UnitMapScreen
import org.maturita.maturita.data.ColorMode
import org.maturita.maturita.ui.VocabExercise import org.maturita.maturita.ui.VocabExercise
import org.maturita.maturita.ui.WelcomeScreen import org.maturita.maturita.ui.appTypography
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val vm: AppViewModel by viewModels() 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 @Composable
fun MaturitaApp(vm: AppViewModel) { fun MaturitaApp(vm: AppViewModel) {
val p = vm.palette MaterialTheme(colorScheme = schemeFor(vm.palette, vm.mode == ColorMode.Dark), typography = appTypography()) {
val scheme = if (vm.mode == ColorMode.Dark) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
darkColorScheme( MaturitaAppBody(vm)
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)
} }
} }
@Composable @Composable
private fun MaturitaAppBody(vm: AppViewModel) { private fun MaturitaAppBody(vm: AppViewModel) {
val p = vm.palette
LaunchedEffect(Unit) { vm.checkUpdate(false) } LaunchedEffect(Unit) { vm.checkUpdate(false) }
BackHandler(vm.canGoBack || vm.searchOpen || vm.settingsOpen) { BackHandler(vm.canGoBack || vm.searchOpen) {
when { if (vm.searchOpen) vm.searchOpen = false else vm.back()
vm.searchOpen -> vm.searchOpen = false
vm.settingsOpen -> vm.settingsOpen = false
else -> vm.back()
}
} }
Box( Box(Modifier.fillMaxSize()) {
Modifier when (val r = vm.route) {
.fillMaxSize() Route.Welcome, Route.Subjects -> HomeScreen(vm)
.background(p.base) Route.Stats -> StatsScreen(vm)
.statusBarsPadding() Route.Settings -> SettingsScreen(vm)
.navigationBarsPadding(), Route.Roadmap -> RoadmapScreen(vm)
) { is Route.UnitMap -> UnitMapScreen(vm, r.unitId)
Column(Modifier.fillMaxSize()) { is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex)
Row( is Route.Vocab -> VocabExercise(vm, r.unitId)
Modifier Route.NetYears -> NetYearsScreen(vm)
.fillMaxWidth() Route.NetMap -> LessonListScreen(
.padding(horizontal = 12.dp, vertical = 6.dp), vm, vm.tr("net_year1"), vm.tr("net_sub"),
verticalAlignment = Alignment.CenterVertically, vm.content.netLessons.map { l ->
) { val id = l.int("id")
Text("maturita.c", style = appTextStyle(15.sp, FontWeight.Medium, color = p.text), modifier = Modifier.weight(1f)) LessonRow(vm.tr(l.str("titleKey")), vm.progress.netDone(id), Route.NetLesson(id))
IconHit({ vm.searchOpen = true }) { SearchIcon(p.text) } },
IconHit({ vm.go(Route.Stats) }) { StatsIcon(p.text) } )
IconHit({ vm.settingsOpen = true }) { SettingsIcon(p.text) } is Route.NetLesson -> {
} val l = vm.content.netLesson(r.id)
if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") { if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
Row( vm.replace(Route.NetEx(r.id))
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.weight(1f)) { is Route.NetEx -> NetQuizScreen(vm, r.id)
when (val r = vm.route) { Route.HwMap -> LessonListScreen(
Route.Welcome -> WelcomeScreen(vm) vm, vm.tr("Technické vybavení"), vm.tr("hw_sub"),
Route.Subjects -> SubjectsScreen(vm) vm.content.hw.map { l ->
Route.Stats -> StatsScreen(vm) val id = l.int("id")
Route.Roadmap -> RoadmapScreen(vm) LessonRow(vm.tr(l.str("titleKey")), vm.progress.hwDone(id), Route.HwLesson(id))
is Route.UnitMap -> UnitMapScreen(vm, r.unitId) },
is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex) )
is Route.Vocab -> VocabExercise(vm, r.unitId) is Route.HwLesson -> {
Route.NetYears -> NetYearsScreen(vm) val l = vm.content.hwLesson(r.id)
Route.NetMap -> LessonMapScreen( if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) {
vm, vm.tr("net_year1"), vm.tr("net_sub"), vm.replace(Route.HwEx(r.id))
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.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.searchOpen) SearchScreen(vm)
if (vm.settingsOpen) {
Box(
Modifier
.fillMaxSize()
.background(p.base),
) { SettingsSheet(vm) }
}
} }
} }

View file

@ -4,6 +4,7 @@ sealed class Route(val page: String) {
data object Welcome : Route("welcome") data object Welcome : Route("welcome")
data object Subjects : Route("subjects") data object Subjects : Route("subjects")
data object Stats : Route("stats") data object Stats : Route("stats")
data object Settings : Route("settings")
data object Roadmap : Route("roadmap") data object Roadmap : Route("roadmap")
data class UnitMap(val unitId: Int) : Route("unit${unitId + 1}") 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") 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 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 BookQuiz(val id: String) : Route(if (id == "1984") "cetba1984quiz" else "cetbaFuksQuiz")
data class BookPlot(val id: String) : Route(if (id == "1984") "cetba1984dej" else "cetbaFuksDej") 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) { fun routeFromPage(page: String): Route? = when (page) {
"welcome" -> Route.Welcome "welcome" -> Route.Welcome
"subjects" -> Route.Subjects "subjects" -> Route.Subjects
"stats" -> Route.Stats "stats" -> Route.Stats
"settings" -> Route.Settings
"roadmap" -> Route.Roadmap "roadmap" -> Route.Roadmap
"netyears" -> Route.NetYears "netyears" -> Route.NetYears
"netmap" -> Route.NetMap "netmap" -> Route.NetMap

View file

@ -36,16 +36,48 @@ data class Palette(
val finishDark: Color, val finishDark: Color,
val finishLight: Color, val finishLight: Color,
val finishStroke: 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 hx(v: Long) = Color(0xFF000000L or v)
private fun pal(vararg c: Long) = Palette( private fun luminance(v: Long): Double {
hx(c[0]), hx(c[1]), hx(c[2]), hx(c[3]), hx(c[4]), hx(c[5]), hx(c[6]), fun channel(c: Long): Double {
hx(c[7]), hx(c[8]), hx(c[9]), hx(c[10]), hx(c[11]), hx(c[12]), hx(c[13]), val s = c / 255.0
hx(c[14]), hx(c[15]), hx(c[16]), hx(c[17]), hx(c[18]), hx(c[19]), hx(c[20]), return if (s <= 0.03928) s / 12.92 else Math.pow((s + 0.055) / 1.055, 2.4)
hx(c[21]), hx(c[22]), hx(c[23]), }
) 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 { fun themePalette(id: ThemeId, mode: ColorMode): Palette {
val light = mode == ColorMode.Light val light = mode == ColorMode.Light

View file

@ -1,38 +1,77 @@
package org.maturita.maturita.ui package org.maturita.maturita.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.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.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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicTextField 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.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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontSynthesis import androidx.compose.ui.text.font.FontSynthesis
import androidx.compose.ui.text.font.FontWeight 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.maturita.maturita.AppViewModel import org.maturita.maturita.AppViewModel
import org.maturita.maturita.data.Palette import org.maturita.maturita.Route
/** Bold is capped at Medium: synthetic bold doubled glyphs on some devices. */
fun appTextStyle( fun appTextStyle(
fontSize: TextUnit, fontSize: TextUnit,
fontWeight: FontWeight = FontWeight.Normal, fontWeight: FontWeight = FontWeight.Normal,
@ -46,186 +85,379 @@ fun appTextStyle(
fontWeight = weight, fontWeight = weight,
fontFamily = FontFamily.SansSerif, fontFamily = FontFamily.SansSerif,
fontSynthesis = FontSynthesis.None, fontSynthesis = FontSynthesis.None,
lineHeight = fontSize * 1.4f, lineHeight = fontSize * 1.35f,
letterSpacing = 0.sp, letterSpacing = 0.sp,
textAlign = textAlign, textAlign = textAlign,
) )
} }
fun appTypography(): Typography { fun appTypography(): Typography = Typography(
val base = appTextStyle(16.sp) displaySmall = appTextStyle(34.sp, FontWeight.Medium),
return Typography( headlineLarge = appTextStyle(30.sp, FontWeight.Medium),
bodyLarge = base, headlineMedium = appTextStyle(26.sp, FontWeight.Medium),
bodyMedium = appTextStyle(14.sp), headlineSmall = appTextStyle(22.sp, FontWeight.Medium),
bodySmall = appTextStyle(12.sp), titleLarge = appTextStyle(20.sp, FontWeight.Medium),
titleLarge = appTextStyle(22.sp, FontWeight.Medium), titleMedium = appTextStyle(16.sp, FontWeight.Medium),
titleMedium = appTextStyle(18.sp, FontWeight.Medium), titleSmall = appTextStyle(14.sp, FontWeight.Medium),
titleSmall = appTextStyle(16.sp, FontWeight.Medium), bodyLarge = appTextStyle(16.sp),
labelLarge = appTextStyle(14.sp, FontWeight.Medium), bodyMedium = appTextStyle(14.sp),
labelMedium = appTextStyle(12.sp, FontWeight.Medium), bodySmall = appTextStyle(12.sp),
headlineMedium = appTextStyle(24.sp, FontWeight.Medium), 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) /** Feedback line plus the primary action, anchored at the bottom of an exercise. */
private val Card = RoundedCornerShape(20.dp)
@Composable @Composable
fun PrimaryButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) { fun ActionBar(
Box( label: String,
modifier feedback: String = "",
.clip(Pill) kind: String = "",
.background(palette.accent) enabled: Boolean = true,
.clickable(onClick = onClick) leading: (@Composable () -> Unit)? = null,
.padding(horizontal = 22.dp, vertical = 12.dp), onClick: () -> Unit,
contentAlignment = Alignment.Center, ) {
) { Surface(color = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 2.dp) {
Text(label, style = appTextStyle(15.sp, FontWeight.Medium, color = palette.onAccent)) Column(
} Modifier
} .fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
@Composable .padding(horizontal = 16.dp, vertical = 12.dp),
fun PillButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) { ) {
Box( if (feedback.isNotEmpty()) {
modifier FeedbackLine(feedback, kind)
.clip(Pill) Spacer(Modifier.height(10.dp))
.background(palette.text.copy(alpha = 0.06f)) }
.clickable(onClick = onClick) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
.padding(horizontal = 16.dp, vertical = 8.dp), leading?.invoke()
) { Button(onClick = onClick, enabled = enabled, modifier = Modifier.weight(1f).height(50.dp)) {
Text(label, style = appTextStyle(13.sp, FontWeight.Medium, color = palette.text)) Text(label, style = MaterialTheme.typography.titleMedium)
} }
}
@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) }
} }
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 @Composable
fun CardBox(palette: Palette, modifier: Modifier = Modifier, content: @Composable () -> Unit) { fun SectionHeader(text: String) {
Box( Text(
modifier text,
.clip(Card) style = MaterialTheme.typography.titleSmall,
.background(palette.mantle) color = MaterialTheme.colorScheme.primary,
.border(1.dp, palette.text.copy(alpha = 0.07f), Card) modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 6.dp),
.padding(20.dp), )
) { content() } }
/** 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 @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 if (text.isEmpty()) return
val color = when (kind) { val color = when (kind) {
"ok" -> palette.success "ok" -> MaterialTheme.colorScheme.primary
"warn" -> palette.warning "warn" -> MaterialTheme.colorScheme.tertiary
else -> palette.error 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 @Composable
fun Meaning(text: String, palette: Palette, visible: Boolean) { fun Meaning(text: String, visible: Boolean) {
if (!visible || text.isEmpty()) return 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 @Composable
fun WordField( fun WordField(
value: String, value: String,
onValue: (String) -> Unit, onValue: (String) -> Unit,
palette: Palette,
placeholder: String, placeholder: String,
mark: Boolean? = null, mark: Boolean? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
numeric: Boolean = false,
) { ) {
val border = when (mark) { OutlinedTextField(
true -> palette.success value = value,
false -> palette.error onValueChange = onValue,
null -> palette.text.copy(alpha = 0.12f) placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder) }) else null,
} singleLine = true,
Box( isError = mark == false,
modifier trailingIcon = when (mark) {
.clip(RoundedCornerShape(14.dp)) true -> ({ Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) })
.background(palette.surface0) false -> ({ Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error) })
.border(1.dp, border, RoundedCornerShape(14.dp)) null -> null
.padding(horizontal = 14.dp, vertical = 12.dp) },
.fillMaxWidth(), keyboardOptions = KeyboardOptions(
) { capitalization = KeyboardCapitalization.None,
if (value.isEmpty()) Text(placeholder, color = palette.overlay, fontSize = 15.sp) autoCorrectEnabled = false,
BasicTextField( keyboardType = if (numeric) KeyboardType.Decimal else KeyboardType.Text,
value = value, ),
onValueChange = onValue, modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
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,
) )
} }
/** Tappable word in a bank or a built sentence. */
@Composable @Composable
fun SegChip(label: String, selected: Boolean, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) { fun Chip(text: String, onClick: () -> Unit) {
Box( SuggestionChip(onClick = onClick, label = { Text(text) })
modifier }
.clip(Pill)
.background(if (selected) palette.mantle else Color.Transparent) @Composable
.clickable(onClick = onClick) fun SegChip(label: String, selected: Boolean, onClick: () -> Unit) {
.padding(vertical = 8.dp), FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
contentAlignment = Alignment.Center, }
/** 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 @Composable
fun Hint(text: String, palette: Palette) { fun WordSlot(empty: Boolean, content: @Composable () -> Unit) {
Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp)) 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 @Composable
fun Prompt(text: String, palette: Palette) { fun WordPicker(title: String, pool: List<String>, onPick: (String?) -> Unit) {
Text(text, color = palette.text, fontSize = 16.sp, fontWeight = FontWeight.Medium) 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 @Composable
fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) { fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) {
DrawnIcon(iconSize) { 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 { .stats-page {
padding: 8px 28px 24px 28px; padding: 24px 32px 24px 32px;
} }
.stats-summary { .stats-summary {
margin-top: 10px; margin-top: 10px;

View file

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

View file

@ -19,6 +19,18 @@ final class AppModel: ObservableObject {
themeId = progress.themeId themeId = progress.themeId
mode = progress.mode mode = progress.mode
lang = progress.lang 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) } var palette: Palette { themePalette(themeId, mode) }

View file

@ -24,6 +24,8 @@ struct Palette {
let success, success2, warning, error: Color let success, success2, warning, error: Color
let onAccent, node, lockedBg, lockedBorder, rail: Color let onAccent, node, lockedBg, lockedBorder, rail: Color
let finishDark, finishLight, finishStroke: 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 { 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 { private func pal(_ c: [UInt32]) -> Palette {
Palette( Palette(
crust: hx(c[0]), base: hx(c[1]), mantle: hx(c[2]), surface0: hx(c[3]), 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]), 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]), onAccent: hx(c[16]), node: hx(c[17]), lockedBg: hx(c[18]),
lockedBorder: hx(c[19]), rail: hx(c[20]), finishDark: hx(c[21]), 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 tabs
} }
} }
.tint(vm.palette.accent) .tint(vm.palette.tint)
.preferredColorScheme(vm.mode == .dark ? .dark : .light) .preferredColorScheme(vm.mode == .dark ? .dark : .light)
.onAppear { vm.checkUpdate(false) } .onAppear { vm.checkUpdate(false) }
} }
@ -151,7 +151,7 @@ struct RouteDestination: View {
case .mluvnice: case .mluvnice:
LessonListScreen( LessonListScreen(
title: vm.tr("Mluvnice"), title: vm.tr("Mluvnice"),
subtitle: "Cvičení z mluvnice", subtitle: vm.tr("mluv_sub"),
rows: vm.content.mluvnice.map { m in rows: vm.content.mluvnice.map { m in
let n = m.int("id") let n = m.int("id")
return LessonRow( return LessonRow(

View file

@ -44,9 +44,13 @@ struct PrimaryButton: View {
var action: () -> Void var action: () -> Void
var body: some View { var body: some View {
Button(label, action: action) Button(action: action) {
.buttonStyle(.borderedProminent) Text(label)
.controlSize(.large) .font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
}
.buttonStyle(.glassProminent)
.controlSize(.large)
} }
} }
@ -78,9 +82,13 @@ struct FeedbackLine: View {
var body: some View { var body: some View {
if !text.isEmpty { if !text.isEmpty {
Text(text) Label {
.font(.subheadline.weight(.semibold)) Text(text)
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error) } 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 palette: Palette?
var body: some View { var body: some View {
TextField(placeholder, text: $value) HStack(spacing: 8) {
.textInputAutocapitalization(.never) TextField(placeholder, text: $value)
.autocorrectionDisabled() .textInputAutocapitalization(.never)
.padding(12) .autocorrectionDisabled()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) if let mark {
.overlay( Image(systemName: mark ? "checkmark.circle.fill" : "xmark.circle.fill")
RoundedRectangle(cornerRadius: 14, style: .continuous) .foregroundStyle(mark ? (palette?.success ?? .green) : (palette?.error ?? .red))
.stroke(border, lineWidth: mark == nil ? 0 : 1.5) }
) }
.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 { private var border: Color {
if mark == true { return palette?.success ?? .green } if mark == true { return palette?.success ?? .green }
if mark == false { return palette?.error ?? .red } if mark == false { return palette?.error ?? .red }
return .clear return Color(.separator)
} }
} }
@ -158,10 +173,29 @@ struct WordChip: View {
var body: some View { var body: some View {
Button(text, action: action) Button(text, action: action)
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.primary)
.controlSize(.small) .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 { struct OptionChip: View {
let text: String let text: String
let selected: Bool let selected: Bool
@ -171,15 +205,32 @@ struct OptionChip: View {
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
HStack { HStack {
Text(text).frame(maxWidth: .infinity, alignment: .leading) Image(systemName: symbol)
if selected { Image(systemName: "checkmark") } .foregroundStyle(symbolColor)
Text(text)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(.vertical, 4)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(tint) .tint(tint)
.padding(.bottom, 4) .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 { private var tint: Color {
if mark == true { return .green } if mark == true { return .green }
if mark == false { return .red } if mark == false { return .red }
@ -196,6 +247,39 @@ extension View {
self 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 { func subjectSymbol(_ icon: String) -> String {
@ -207,3 +291,24 @@ func subjectSymbol(_ icon: String) -> String {
default: return "lock.fill" 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 let p = vm.palette
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 8) { content() } VStack(alignment: .leading, spacing: 8) { content() }
.padding() .exerciseContent()
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(sub)
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button(action, action: onAction)
.buttonStyle(.borderedProminent)
}
} }
.scrollDismissesKeyboard(.interactively)
.detailScreen(title, subtitle: sub)
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
if !feedback.isEmpty { BottomAction(label: action, action: onAction) {
FeedbackLine(text: feedback, kind: kind, palette: p) if !feedback.isEmpty {
.padding() FeedbackLine(text: feedback, kind: kind, palette: p)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) }
.padding(.horizontal)
.padding(.bottom, 4)
} }
} }
.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 "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 "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) 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) { }, feedback: fb.0, kind: fb.1) {
ForEach(qs.indices, id: \.self) { i in ForEach(qs.indices, id: \.self) { i in
Text("\(i + 1). \(qs[i].str("prompt"))") Text("\(i + 1). \(qs[i].str("prompt"))")
.font(.system(size: 16, weight: .semibold)) .font(.body.weight(.semibold))
.foregroundStyle(p.text)
.padding(.bottom, 6) .padding(.bottom, 6)
ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in
let sel = picks[i] == o let sel = picks[i] == o
@ -196,20 +187,18 @@ private struct AssemblyEx: View {
}, feedback: fb.0, kind: fb.1) { }, feedback: fb.0, kind: fb.1) {
ForEach(items.indices, id: \.self) { i in ForEach(items.indices, id: \.self) { i in
if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt) } if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt) }
FlowLayout { WordSlot(empty: placed[i].isEmpty) {
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) } FlowLayout {
ForEach(placed[i], id: \.self) { idx in ForEach(placed[i], id: \.self) { idx in
if idx < items[i].strs("words").count { if idx < items[i].strs("words").count {
WordChip(text: items[i].strs("words")[idx]) { WordChip(text: items[i].strs("words")[idx]) {
placed[i].removeAll { $0 == idx } placed[i].removeAll { $0 == idx }
pools[i].append(idx) pools[i].append(idx)
}
} }
} }
} }
} }
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.bottom, 6) .padding(.bottom, 6)
FlowLayout(spacing: 6) { FlowLayout(spacing: 6) {
ForEach(pools[i], id: \.self) { idx in ForEach(pools[i], id: \.self) { idx in
@ -395,7 +384,7 @@ private struct AssignEx: View {
SegChip(label: groups[i], selected: active == i) { active = i } 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) { FlowLayout(spacing: 6) {
ForEach(items.indices, id: \.self) { i in ForEach(items.indices, id: \.self) { i in
if loc[i] == -1 { if loc[i] == -1 {
@ -405,7 +394,7 @@ private struct AssignEx: View {
} }
} }
ForEach(groups.indices, id: \.self) { g in 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) { FlowLayout(spacing: 6) {
ForEach(items.indices, id: \.self) { i in ForEach(items.indices, id: \.self) { i in
if loc[i] == g { if loc[i] == g {
@ -451,7 +440,8 @@ private struct HangmanEx: View {
let letters = spec.strs("letters") let letters = spec.strs("letters")
let p = vm.palette let p = vm.palette
let cur = word < words.count ? words[word].uppercased() : "" 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) 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]))") } if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))") }
Canvas { ctx, size in Canvas { ctx, size in
@ -461,30 +451,30 @@ private struct HangmanEx: View {
gallows.move(to: CGPoint(x: 70, y: size.height - 10)) gallows.move(to: CGPoint(x: 70, y: size.height - 10))
gallows.addLine(to: CGPoint(x: 70, y: 16)) gallows.addLine(to: CGPoint(x: 70, y: 16))
gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, 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 let x = size.width * 0.3 + 40
if misses > 0 { if misses > 0 {
var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36)) 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 { 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 { if misses > 2 {
var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110)) 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 { if misses > 3 {
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100)) 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 { if misses > 4 {
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100)) 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 { if misses > 5 {
var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140)) 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) .frame(height: 160)
@ -493,13 +483,14 @@ private struct HangmanEx: View {
let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame } let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
let show = idx.map { guessed[$0] } ?? false let show = idx.map { guessed[$0] } ?? false
Text(show ? String(ch) : "_") Text(show ? String(ch) : "_")
.font(.system(size: 26, weight: .bold)) .font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundStyle(p.text) .monospaced()
} }
} }
ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in .frame(maxWidth: .infinity)
HStack(spacing: 4) { .padding(.vertical, 8)
ForEach(row, id: \.self) { letter in FlowLayout(spacing: 6) {
ForEach(letters, id: \.self) { letter in
if let i = letters.firstIndex(of: letter) { if let i = letters.firstIndex(of: letter) {
Button { Button {
guessed[i] = true guessed[i] = true
@ -523,17 +514,14 @@ private struct HangmanEx: View {
} }
} label: { } label: {
Text(letter) Text(letter)
.font(.system(size: 15, weight: .bold)) .font(.body.weight(.semibold))
.foregroundStyle(p.text) .frame(minWidth: 22)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(guessed[i] ? p.surface1 : p.mantle, in: RoundedRectangle(cornerRadius: 8))
} }
.buttonStyle(.plain) .buttonStyle(.bordered)
.tint(guessed[i] ? .secondary : .primary)
.disabled(guessed[i] || finished) .disabled(guessed[i] || finished)
} }
} }
}
} }
FeedbackLine(text: fb.0, kind: fb.1, palette: p) FeedbackLine(text: fb.0, kind: fb.1, palette: p)
if misses >= 6 && !finished { if misses >= 6 && !finished {
@ -543,12 +531,10 @@ private struct HangmanEx: View {
fb = ("", "") fb = ("", "")
} }
} }
Spacer()
} }
.padding() .exerciseContent()
.navigationTitle(title) }
.navigationBarTitleDisplayMode(.inline) .detailScreen(title, subtitle: sub)
.navSubtitle(sub)
} }
} }
@ -586,40 +572,63 @@ private struct VocabEx: View {
var body: some View { var body: some View {
let p = vm.palette let p = vm.palette
VStack(alignment: .leading, spacing: 8) { ScrollView {
Text(vm.fmt("trans_progress", done, total)).foregroundStyle(.secondary) 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 { 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 { } else if index < cards.count {
let cur = cards[index] let cur = cards[index]
Hint(text: cur.0) GlassCard {
Prompt(text: cur.1) VStack(alignment: .leading, spacing: 6) {
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) } Text(cur.0).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p) Text(cur.1).font(.title2.weight(.semibold))
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 = ""
} }
} }
.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) FeedbackLine(text: fb.0, kind: fb.1, palette: p)
} }
Spacer()
} }
.padding() .exerciseContent()
.navigationTitle(title) }
.navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively)
.navSubtitle(sub) .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] let line = lines[i]
if let name = line.name { if let name = line.name {
Text(name) Text(name)
.font(.system(size: 15, weight: .semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(vm.palette.subtext) .foregroundStyle(.secondary)
.padding(.vertical, 6) .padding(.vertical, 6)
} }
ComboLine( 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 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 ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in
if let title = line.title { 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( ComboLine(
vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx, 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 ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
FlowLayout(spacing: 4) { FlowLayout(spacing: 4) {
if let num = block.num { 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 ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
switch part { switch part {
case .text(let s): case .text(let s):
Text(s).foregroundStyle(vm.palette.text) Text(s).foregroundStyle(.primary)
case .blank(let idx, let ans): case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true) ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
} }
} }
if let mean = block.meaning { 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) .padding(.bottom, 6)
@ -267,35 +267,36 @@ private struct ComboLine: View {
let chip = Button { let chip = Button {
open = true open = true
} label: { } label: {
Text(pick.isEmpty ? "—" : pick) HStack(spacing: 4) {
.font(.system(size: 15)) Text(pick.isEmpty ? "…" : pick)
.foregroundStyle(pick.isEmpty ? p.overlay : p.text) .foregroundStyle(pick.isEmpty ? Color.secondary : Color.primary)
.padding(.horizontal, 12) .frame(minWidth: 36)
.padding(.vertical, 8) Image(systemName: mark == true ? "checkmark.circle.fill" : mark == false ? "xmark.circle.fill" : "chevron.up.chevron.down")
.background(p.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .font(.caption2)
.overlay( .foregroundStyle(mark == true ? p.success : mark == false ? p.error : Color.secondary)
RoundedRectangle(cornerRadius: 12, style: .continuous) }
.stroke(mark == true ? p.success : mark == false ? p.error : p.text.opacity(0.12), lineWidth: 1)
)
} }
.buttonStyle(.plain) .buttonStyle(.bordered)
.controlSize(.small)
.tint(mark == true ? p.success : mark == false ? p.error : .secondary)
Group { Group {
if inline { if inline {
chip chip
} else { } else {
FlowLayout(spacing: 6) { FlowLayout(spacing: 6) {
if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) } if !prefix.isEmpty { Text(prefix) }
chip 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) .padding(.bottom, 8)
} }
} }
.onAppear { .onAppear {
if idx >= 0 && idx < expected.count { expected[idx] = answer } 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 } if idx >= 0 && idx < expected.count { expected[idx] = new }
} }
.sheet(isPresented: $open) { .sheet(isPresented: $open) {
@ -319,16 +320,22 @@ struct WordPicker: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List(Array(pool.enumerated()), id: \.offset) { _, w in 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 { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Zavřít", role: .cancel) { onPick(nil) } Button(role: .cancel) { onPick(nil) } label: { Image(systemName: "xmark") }
} }
} }
} }
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} }
} }
@ -351,12 +358,12 @@ struct Ex2Ex: View {
ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in
FlowLayout(spacing: 4) { FlowLayout(spacing: 4) {
if let num = row.num { 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 ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
switch part { switch part {
case .text(let s): case .text(let s):
Text(s).foregroundStyle(vm.palette.text) Text(s).foregroundStyle(.primary)
case .blank(let idx, let ans): case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true) 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 { var body: some View {
let rows = spec.arr("rows") let rows = spec.arr("rows")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
var i = 0, ok = 0, total = 0 var i = 0, ok = 0, total = 0
for r in rows { for r in rows {
@ -449,12 +455,12 @@ private struct LettersRows: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(pairs.indices, id: \.self) { ri in ForEach(pairs.indices, id: \.self) { ri in
let row = pairs[ri] let row = pairs[ri]
if let num = row.num { Text(num).foregroundStyle(p.subtext) } if let num = row.num { Text(num).foregroundStyle(.secondary) }
FlowLayout { FlowLayout {
ForEach(row.parts.indices, id: \.self) { pi in ForEach(row.parts.indices, id: \.self) { pi in
switch row.parts[pi] { switch row.parts[pi] {
case .text(let s): case .text(let s):
Text(s).foregroundStyle(p.text) Text(s).foregroundStyle(.primary)
case .field(let idx, let ans): case .field(let idx, let ans):
WordField( WordField(
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }), 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 revealed = true
}, feedback: fb.0, kind: fb.1) { }, feedback: fb.0, kind: fb.1) {
ForEach(nouns.indices, id: \.self) { r in 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 ForEach(persons.indices, id: \.self) { c in
let i = r * persons.count + c 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) WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), placeholder: "", palette: p)
} }
Spacer().frame(height: 8) Spacer().frame(height: 8)
@ -658,29 +664,41 @@ private struct VlsmEx: View {
let tasks = vm.content.netTasks let tasks = vm.content.netTasks
let answers = vm.content.netAnswers let answers = vm.content.netAnswers
let p = vm.palette let p = vm.palette
VStack(alignment: .leading, spacing: 0) { ScrollView {
ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
ForEach(tasks.indices, id: \.self) { ti in ForEach(tasks.indices, id: \.self) { ti in
let task = tasks[ti] let task = tasks[ti]
let block = ti < answers.count ? answers[ti] : [] let block = ti < answers.count ? answers[ti] : []
let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 } 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 { if infeasible {
Hint(text: vm.tr("net_infeasible")) Hint(text: vm.tr("net_infeasible"))
} else { } else {
ForEach(block.indices, id: \.self) { si in ForEach(block.indices, id: \.self) { si in
Text("Podsíť \(String(UnicodeScalar(65 + si)!))") Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
.font(.system(size: 14, weight: .semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(p.subtext) .foregroundStyle(.secondary)
.padding(.top, 6)
PrefixMenu(value: Binding( PrefixMenu(value: Binding(
get: { rows[ti][si].pfx }, get: { rows[ti][si].pfx },
set: { rows[ti][si].pfx = $0 } set: { rows[ti][si].pfx = $0 }
), palette: p) ), palette: p)
WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), placeholder: "síť", 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) 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) 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) 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")) { PillButton(label: vm.tr("check")) {
let ok = block.indices.allSatisfy { si in let ok = block.indices.allSatisfy { si in
@ -717,14 +735,22 @@ private struct VlsmEx: View {
maybeFinish() maybeFinish()
} }
if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) } if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) }
Divider().padding(.vertical, 8)
} }
} }
} .exerciseContent()
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
} }
.padding() .scrollDismissesKeyboard(.interactively)
.navigationTitle(vm.tr("net_ex_title")) .detailScreen(vm.tr("net_ex_title"))
.navigationBarTitleDisplayMode(.inline) .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() { private func maybeFinish() {
@ -739,15 +765,17 @@ private struct PrefixMenu: View {
var body: some View { var body: some View {
Button { open = true } label: { Button { open = true } label: {
Text(value.isEmpty ? "/…" : value) HStack {
.font(.system(size: 15)) Text("Prefix").foregroundStyle(.secondary)
.foregroundStyle(palette.text) Spacer()
.padding(10) Text(value.isEmpty ? "/…" : value)
.frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(value.isEmpty ? Color.secondary : Color.primary)
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .monospacedDigit()
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
}
} }
.buttonStyle(.plain) .buttonStyle(.bordered)
.padding(.vertical, 4) .tint(.secondary)
.sheet(isPresented: $open) { .sheet(isPresented: $open) {
WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
if let word { value = word } if let word { value = word }
@ -765,63 +793,75 @@ struct LitQuizScreen: View {
@State private var score = 0 @State private var score = 0
@State private var answered = false @State private var answered = false
@State private var finished = false @State private var finished = false
@State private var picked = -1
var body: some View { var body: some View {
if let book = vm.content.book(id) { if let book = vm.content.book(id) {
let qs = book.arr("quiz") let qs = book.arr("quiz")
let p = vm.palette let p = vm.palette
let kahoot = [p.accent, p.error, p.warning, p.success] ScrollView {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if finished { if finished {
Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text) GlassCard {
Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext) VStack(alignment: .leading, spacing: 8) {
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) Label(vm.tr("lit_finished"), systemImage: "rosette")
PrimaryButton(label: vm.tr("lit_again")) { .font(.title2.weight(.bold))
idx = 0; score = 0; answered = false; finished = false 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]
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
} }
} }
} 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() .detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
.navigationTitle(vm.tr(book.str("quizTitle"))) .safeAreaInset(edge: .bottom) {
.navigationBarTitleDisplayMode(.inline) if finished {
.navigationSubtitle(vm.tr(book.str("quizSub"))) 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 import SwiftUI
struct BackIcon: View { // Circular flags used as subject glyphs; everything else is an SF Symbol.
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)
}
}
struct GermanFlag: View { struct GermanFlag: View {
var size: CGFloat = 48 var size: CGFloat = 48
@ -193,112 +33,3 @@ struct CzechFlag: View {
.frame(width: size, height: size) .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: { } 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) vm.setTheme(id)
} label: { } label: {
HStack { HStack {
Circle().fill(themePalette(id, vm.mode).accent).frame(width: 14, height: 14) Circle().fill(themePalette(id, vm.mode).tint).frame(width: 14, height: 14)
Text(themeNames[id.rawValue]) Text(themeNames[id.rawValue]).foregroundStyle(.primary)
Spacer() Spacer()
if vm.themeId == id { 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) let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
List { List {
Section { Section {
HStack { GlassCard {
stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx)) VStack(alignment: .leading, spacing: 14) {
stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100))) HStack(alignment: .top) {
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits)) 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) .listRowBackground(Color.clear)
ProgressView(value: pct) .listRowSeparator(.hidden)
} }
Section(vm.tr("stats_section")) { Section(vm.tr("stats_section")) {
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
let open = s.bool("open") let open = s.bool("open")
let part = subjectSum(vm, s) let part = subjectSum(vm, s)
VStack(alignment: .leading, spacing: 6) { Label {
Text(vm.tr(s.str("key"))) VStack(alignment: .leading, spacing: 6) {
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked")) HStack {
.font(.caption) Text(vm.tr(s.str("key")))
.foregroundStyle(.secondary) Spacer()
if open && part.totalEx > 0 { Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
ProgressView(value: Double(part.doneEx) / Double(part.totalEx)) .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 { private func stat(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 2) {
Text(label).font(.caption).foregroundStyle(.secondary) Text(label).font(.caption).foregroundStyle(.secondary)
Text(value).font(.title3.bold()) Text(value).font(.title3.bold()).monospacedDigit()
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@ -397,29 +411,59 @@ struct SlidesScreen: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(slide.str("title")).font(.title2.bold()) Text(slide.str("title")).font(.title2.bold())
if let tip = slide.strOrNull("tip") { 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 ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
Text(line).padding(.vertical, 4) Text(line).padding(.vertical, 4)
} }
} }
.padding() .padding()
.padding(.bottom, 24)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.tag(i) .tag(i)
} }
} }
.tabViewStyle(.page(indexDisplayMode: .automatic)) .tabViewStyle(.page(indexDisplayMode: .never))
.navigationTitle(title) .detailScreen(title, subtitle: subtitle)
.navigationSubtitle(subtitle) .safeAreaInset(edge: .bottom) {
.toolbar { VStack(spacing: 10) {
ToolbarItem(placement: .bottomBar) { HStack(spacing: 6) {
if idx < slides.count - 1 { ForEach(slides.indices, id: \.self) { i in
Button(vm.tr("net_slide_next")) { idx += 1 } Capsule()
} else { .fill(i == idx ? Color.accentColor : Color(.tertiarySystemFill))
NavigationLink(vm.tr("net_slide_start"), value: next) .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", "Statistiky", "Statistics"},
{"stats_title", "Statistiky", "Statistics"}, {"stats_title", "Statistiky", "Statistics"},
{"stats_sub", "Přehled vašeho pokroku.", "Overview of your progress."}, {"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_ex_label", "Cvičení", "Exercises"},
{"stats_pct_label", "Hotovo", "Done"}, {"stats_pct_label", "Hotovo", "Done"},
{"stats_units_label", "Jednotky", "Units"}, {"stats_units_label", "Jednotky", "Units"},

View file

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

View file

@ -2669,7 +2669,10 @@ GtkWidget *net_qz_make_entry(void) {
GtkWidget *e = gtk_entry_new(); GtkWidget *e = gtk_entry_new();
gtk_widget_set_hexpand(e, TRUE); 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; return e;
} }
@ -2688,8 +2691,10 @@ GtkWidget *build_net_exercise_page(void) {
top_bar("netunit1", "net_ex_title", NULL)); top_bar("netunit1", "net_ex_title", NULL));
scroll = gtk_scrolled_window_new(); 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_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_vexpand(scroll, TRUE);
gtk_widget_set_margin_top(scroll, 12); gtk_widget_set_margin_top(scroll, 12);
gtk_box_append(GTK_BOX(page), scroll); 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_row_spacing(GTK_GRID(table), 6);
gtk_grid_set_column_spacing(GTK_GRID(table), 8); gtk_grid_set_column_spacing(GTK_GRID(table), 8);
gtk_widget_set_margin_top(table, 6); gtk_widget_set_margin_top(table, 6);
gtk_widget_set_hexpand(table, TRUE);
gtk_box_append(GTK_BOX(task), table); gtk_box_append(GTK_BOX(task), table);
for (guint c = 1; c < 6; c++) { 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); outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(outer, "stats-page"); gtk_widget_add_css_class(outer, "stats-page");
/* Same header rhythm as the other pages (top_bar). */
top = gtk_center_box_new(); top = gtk_center_box_new();
gtk_widget_set_margin_top(top, 2); gtk_widget_add_css_class(top, "page-top");
gtk_widget_set_margin_bottom(top, 6); gtk_widget_set_margin_top(top, 8);
gtk_widget_set_margin_bottom(top, 18);
gtk_box_append(GTK_BOX(outer), top); gtk_box_append(GTK_BOX(outer), top);
back = make_back_button("subjects"); back = make_back_button("subjects");