mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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:
parent
a1abac51f5
commit
0621311b4c
25 changed files with 1965 additions and 1836 deletions
|
|
@ -13,8 +13,8 @@ android {
|
|||
applicationId = "org.maturita.maturita"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 5
|
||||
versionName = "android-5"
|
||||
versionCode = 6
|
||||
versionName = "android-6"
|
||||
buildConfigField("String", "COMMIT", "\"$commit\"")
|
||||
buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"")
|
||||
buildConfigField("String", "UPDATE_BRANCH", "\"builds\"")
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||
private set
|
||||
var lang by mutableStateOf(progress.lang)
|
||||
private set
|
||||
var settingsOpen by mutableStateOf(false)
|
||||
var searchOpen by mutableStateOf(false)
|
||||
var searchQuery by mutableStateOf("")
|
||||
var tick by mutableStateOf(0)
|
||||
|
|
@ -34,19 +33,32 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||
var update by mutableStateOf(UpdateState())
|
||||
private set
|
||||
|
||||
private val stack = mutableStateListOf<Route>(Route.Welcome)
|
||||
private val stack = mutableStateListOf<Route>(Route.Subjects)
|
||||
val route: Route get() = stack.last()
|
||||
val canGoBack: Boolean get() = stack.size > 1
|
||||
val root: Route get() = stack.first()
|
||||
val canGoBack: Boolean get() = stack.size > 1 || root != Route.Subjects
|
||||
|
||||
val palette get() = themePalette(themeId, mode)
|
||||
|
||||
fun tr(key: String?) = content.tr(key, lang)
|
||||
fun fmt(key: String, vararg args: Any) = content.fmt(key, lang, *args)
|
||||
|
||||
/** Roots switch the bottom tab; everything else is pushed on the practice stack. */
|
||||
fun go(r: Route) {
|
||||
if (stack.last() == r) return
|
||||
stack.add(r)
|
||||
searchOpen = false
|
||||
if (stack.last() == r) return
|
||||
if (r.isRoot) {
|
||||
val target = if (r == Route.Welcome) Route.Subjects else r
|
||||
stack.clear()
|
||||
stack.add(target)
|
||||
return
|
||||
}
|
||||
if (root != Route.Subjects) {
|
||||
stack.clear()
|
||||
stack.addAll(r.stack())
|
||||
return
|
||||
}
|
||||
stack.add(r)
|
||||
}
|
||||
|
||||
fun goPage(page: String?) {
|
||||
|
|
@ -54,8 +66,25 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||
go(r)
|
||||
}
|
||||
|
||||
fun back() {
|
||||
/** Jumping from search rebuilds the whole path so back walks up level by level. */
|
||||
fun openPage(page: String) {
|
||||
val r = routeFromPage(page) ?: return
|
||||
searchOpen = false
|
||||
stack.clear()
|
||||
stack.addAll(r.stack())
|
||||
}
|
||||
|
||||
/** Replace the current screen (lesson slides -> its exercise). */
|
||||
fun replace(r: Route) {
|
||||
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||
stack.add(r)
|
||||
}
|
||||
|
||||
fun back() {
|
||||
when {
|
||||
stack.size > 1 -> stack.removeAt(stack.lastIndex)
|
||||
root != Route.Subjects -> go(Route.Subjects)
|
||||
}
|
||||
}
|
||||
|
||||
fun setTheme(id: ThemeId) {
|
||||
|
|
|
|||
|
|
@ -12,56 +12,39 @@ import androidx.activity.compose.BackHandler
|
|||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.maturita.maturita.data.ColorMode
|
||||
import org.maturita.maturita.data.Palette
|
||||
import org.maturita.maturita.ui.BookListScreen
|
||||
import org.maturita.maturita.ui.BookScreen
|
||||
import org.maturita.maturita.ui.CzechMapScreen
|
||||
import org.maturita.maturita.ui.GermanExercise
|
||||
import org.maturita.maturita.ui.HomeScreen
|
||||
import org.maturita.maturita.ui.HwQuizScreen
|
||||
import org.maturita.maturita.ui.IconHit
|
||||
import org.maturita.maturita.ui.LessonMapScreen
|
||||
import org.maturita.maturita.ui.LessonListScreen
|
||||
import org.maturita.maturita.ui.LessonRow
|
||||
import org.maturita.maturita.ui.LitQuizScreen
|
||||
import org.maturita.maturita.ui.MapNode
|
||||
import org.maturita.maturita.ui.MluvExercise
|
||||
import org.maturita.maturita.ui.NetQuizScreen
|
||||
import org.maturita.maturita.ui.NetYearsScreen
|
||||
import org.maturita.maturita.ui.PlotScreen
|
||||
import org.maturita.maturita.ui.RoadmapScreen
|
||||
import org.maturita.maturita.ui.SearchIcon
|
||||
import org.maturita.maturita.ui.SearchOverlay
|
||||
import org.maturita.maturita.ui.SettingsIcon
|
||||
import org.maturita.maturita.ui.SettingsSheet
|
||||
import org.maturita.maturita.ui.SearchScreen
|
||||
import org.maturita.maturita.ui.SettingsScreen
|
||||
import org.maturita.maturita.ui.SlidesScreen
|
||||
import org.maturita.maturita.ui.appTextStyle
|
||||
import org.maturita.maturita.ui.appTypography
|
||||
import org.maturita.maturita.ui.StatsIcon
|
||||
import org.maturita.maturita.ui.StatsScreen
|
||||
import org.maturita.maturita.ui.SubjectsScreen
|
||||
import org.maturita.maturita.ui.UnitMapScreen
|
||||
import org.maturita.maturita.data.ColorMode
|
||||
import org.maturita.maturita.ui.VocabExercise
|
||||
import org.maturita.maturita.ui.WelcomeScreen
|
||||
import org.maturita.maturita.ui.appTypography
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val vm: AppViewModel by viewModels()
|
||||
|
|
@ -125,149 +108,129 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Material 3 colour roles from the shared theme palette. */
|
||||
private fun schemeFor(p: Palette, dark: Boolean) = if (dark) {
|
||||
darkColorScheme(
|
||||
primary = p.tint,
|
||||
onPrimary = p.onTint,
|
||||
primaryContainer = p.accent,
|
||||
onPrimaryContainer = p.onAccent,
|
||||
secondary = p.accent2,
|
||||
onSecondary = p.onAccent,
|
||||
secondaryContainer = p.surface1,
|
||||
onSecondaryContainer = p.text,
|
||||
tertiary = p.warning,
|
||||
onTertiary = p.crust,
|
||||
background = p.base,
|
||||
onBackground = p.text,
|
||||
surface = p.base,
|
||||
onSurface = p.text,
|
||||
surfaceVariant = p.surface0,
|
||||
onSurfaceVariant = p.subtext,
|
||||
surfaceContainerLowest = p.crust,
|
||||
surfaceContainerLow = p.mantle,
|
||||
surfaceContainer = p.mantle,
|
||||
surfaceContainerHigh = p.surface0,
|
||||
surfaceContainerHighest = p.surface1,
|
||||
outline = p.overlay,
|
||||
outlineVariant = p.surface1,
|
||||
error = p.error,
|
||||
onError = Color.White,
|
||||
errorContainer = p.error.copy(alpha = 0.22f),
|
||||
onErrorContainer = p.text,
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = p.tint,
|
||||
onPrimary = p.onTint,
|
||||
primaryContainer = p.accent,
|
||||
onPrimaryContainer = p.onAccent,
|
||||
secondary = p.accent2,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = p.surface1,
|
||||
onSecondaryContainer = p.text,
|
||||
tertiary = p.warning,
|
||||
onTertiary = Color.White,
|
||||
background = p.base,
|
||||
onBackground = p.text,
|
||||
surface = p.base,
|
||||
onSurface = p.text,
|
||||
surfaceVariant = p.surface1,
|
||||
onSurfaceVariant = p.subtext,
|
||||
surfaceContainerLowest = p.mantle,
|
||||
surfaceContainerLow = p.mantle,
|
||||
surfaceContainer = p.mantle,
|
||||
surfaceContainerHigh = p.surface1,
|
||||
surfaceContainerHighest = p.surface2,
|
||||
outline = p.overlay,
|
||||
outlineVariant = p.surface2,
|
||||
error = p.error,
|
||||
onError = Color.White,
|
||||
errorContainer = p.error.copy(alpha = 0.14f),
|
||||
onErrorContainer = p.text,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MaturitaApp(vm: AppViewModel) {
|
||||
val p = vm.palette
|
||||
val scheme = if (vm.mode == ColorMode.Dark) {
|
||||
darkColorScheme(
|
||||
primary = p.accent,
|
||||
onPrimary = p.onAccent,
|
||||
secondary = p.accent2,
|
||||
background = p.base,
|
||||
onBackground = p.text,
|
||||
surface = p.mantle,
|
||||
onSurface = p.text,
|
||||
surfaceVariant = p.surface1,
|
||||
onSurfaceVariant = p.subtext,
|
||||
error = p.error,
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = p.accent,
|
||||
onPrimary = p.onAccent,
|
||||
secondary = p.accent2,
|
||||
background = p.base,
|
||||
onBackground = p.text,
|
||||
surface = p.mantle,
|
||||
onSurface = p.text,
|
||||
surfaceVariant = p.surface1,
|
||||
onSurfaceVariant = p.subtext,
|
||||
error = p.error,
|
||||
)
|
||||
}
|
||||
MaterialTheme(colorScheme = scheme, typography = appTypography()) {
|
||||
MaterialTheme(colorScheme = schemeFor(vm.palette, vm.mode == ColorMode.Dark), typography = appTypography()) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
MaturitaAppBody(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MaturitaAppBody(vm: AppViewModel) {
|
||||
val p = vm.palette
|
||||
LaunchedEffect(Unit) { vm.checkUpdate(false) }
|
||||
BackHandler(vm.canGoBack || vm.searchOpen || vm.settingsOpen) {
|
||||
when {
|
||||
vm.searchOpen -> vm.searchOpen = false
|
||||
vm.settingsOpen -> vm.settingsOpen = false
|
||||
else -> vm.back()
|
||||
BackHandler(vm.canGoBack || vm.searchOpen) {
|
||||
if (vm.searchOpen) vm.searchOpen = false else vm.back()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(p.base)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("maturita.c", style = appTextStyle(15.sp, FontWeight.Medium, color = p.text), modifier = Modifier.weight(1f))
|
||||
IconHit({ vm.searchOpen = true }) { SearchIcon(p.text) }
|
||||
IconHit({ vm.go(Route.Stats) }) { StatsIcon(p.text) }
|
||||
IconHit({ vm.settingsOpen = true }) { SettingsIcon(p.text) }
|
||||
}
|
||||
if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(p.surface1)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())),
|
||||
color = p.text,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (vm.update.canInstall) {
|
||||
org.maturita.maturita.ui.PillButton(vm.tr("update_install"), p) { vm.installUpdate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f)) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
when (val r = vm.route) {
|
||||
Route.Welcome -> WelcomeScreen(vm)
|
||||
Route.Subjects -> SubjectsScreen(vm)
|
||||
Route.Welcome, Route.Subjects -> HomeScreen(vm)
|
||||
Route.Stats -> StatsScreen(vm)
|
||||
Route.Settings -> SettingsScreen(vm)
|
||||
Route.Roadmap -> RoadmapScreen(vm)
|
||||
is Route.UnitMap -> UnitMapScreen(vm, r.unitId)
|
||||
is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex)
|
||||
is Route.Vocab -> VocabExercise(vm, r.unitId)
|
||||
Route.NetYears -> NetYearsScreen(vm)
|
||||
Route.NetMap -> LessonMapScreen(
|
||||
Route.NetMap -> LessonListScreen(
|
||||
vm, vm.tr("net_year1"), vm.tr("net_sub"),
|
||||
vm.content.netLessons.map { l ->
|
||||
val id = l.int("id")
|
||||
MapNode(
|
||||
"n$id", vm.tr(l.str("titleKey")), false,
|
||||
vm.progress.netDone(id), !vm.progress.netDone(id),
|
||||
) { vm.go(Route.NetLesson(id)) }
|
||||
LessonRow(vm.tr(l.str("titleKey")), vm.progress.netDone(id), 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))
|
||||
vm.replace(Route.NetEx(r.id))
|
||||
}
|
||||
}
|
||||
is Route.NetEx -> NetQuizScreen(vm, r.id)
|
||||
Route.HwMap -> LessonMapScreen(
|
||||
Route.HwMap -> LessonListScreen(
|
||||
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)) }
|
||||
LessonRow(vm.tr(l.str("titleKey")), vm.progress.hwDone(id), 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))
|
||||
vm.replace(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",
|
||||
Route.Mluvnice -> LessonListScreen(
|
||||
vm, vm.tr("Mluvnice"), vm.tr("mluv_sub"),
|
||||
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)) }
|
||||
LessonRow(m.str("name"), vm.progress.mluvDone(n), 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)
|
||||
|
|
@ -275,15 +238,6 @@ private fun MaturitaAppBody(vm: AppViewModel) {
|
|||
is Route.BookQuiz -> LitQuizScreen(vm, r.id)
|
||||
is Route.BookPlot -> PlotScreen(vm, r.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vm.searchOpen) SearchOverlay(vm)
|
||||
if (vm.settingsOpen) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(p.base),
|
||||
) { SettingsSheet(vm) }
|
||||
}
|
||||
if (vm.searchOpen) SearchScreen(vm)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ sealed class Route(val page: String) {
|
|||
data object Welcome : Route("welcome")
|
||||
data object Subjects : Route("subjects")
|
||||
data object Stats : Route("stats")
|
||||
data object Settings : Route("settings")
|
||||
data object Roadmap : Route("roadmap")
|
||||
data class UnitMap(val unitId: Int) : Route("unit${unitId + 1}")
|
||||
data class GermanEx(val unitId: Int, val ex: Int) : Route("u${unitId + 1}e$ex")
|
||||
|
|
@ -22,12 +23,46 @@ sealed class Route(val page: String) {
|
|||
data class Book(val id: String) : Route(if (id == "1984") "cetba1984" else "cetbaFuks")
|
||||
data class BookQuiz(val id: String) : Route(if (id == "1984") "cetba1984quiz" else "cetbaFuksQuiz")
|
||||
data class BookPlot(val id: String) : Route(if (id == "1984") "cetba1984dej" else "cetbaFuksDej")
|
||||
|
||||
/** Bottom-navigation roots; everything else is pushed on top of [Subjects]. */
|
||||
val isRoot: Boolean get() = this is Welcome || this is Subjects || this is Stats || this is Settings
|
||||
|
||||
/** Screens with their own bottom action bar; the navigation bar stays hidden there. */
|
||||
val isExercise: Boolean
|
||||
get() = this is GermanEx || this is Vocab || this is NetEx || this is HwEx ||
|
||||
this is MluvEx || this is BookQuiz || this is BookPlot || this is NetLesson || this is HwLesson
|
||||
|
||||
/** Full stack from the practice root to this screen, so back always goes up one level. */
|
||||
fun stack(): List<Route> = when (this) {
|
||||
Welcome, Subjects -> listOf(Subjects)
|
||||
Stats -> listOf(Stats)
|
||||
Settings -> listOf(Settings)
|
||||
Roadmap -> listOf(Subjects, Roadmap)
|
||||
is UnitMap -> listOf(Subjects, Roadmap, this)
|
||||
is GermanEx -> listOf(Subjects, Roadmap, UnitMap(unitId), this)
|
||||
is Vocab -> listOf(Subjects, Roadmap, UnitMap(unitId), this)
|
||||
NetYears -> listOf(Subjects, NetYears)
|
||||
NetMap -> listOf(Subjects, NetYears, NetMap)
|
||||
is NetLesson -> listOf(Subjects, NetYears, NetMap, this)
|
||||
is NetEx -> listOf(Subjects, NetYears, NetMap, NetLesson(id), this)
|
||||
HwMap -> listOf(Subjects, HwMap)
|
||||
is HwLesson -> listOf(Subjects, HwMap, this)
|
||||
is HwEx -> listOf(Subjects, HwMap, HwLesson(id), this)
|
||||
CzechMap -> listOf(Subjects, CzechMap)
|
||||
Mluvnice -> listOf(Subjects, CzechMap, Mluvnice)
|
||||
is MluvEx -> listOf(Subjects, CzechMap, Mluvnice, this)
|
||||
ReadingList -> listOf(Subjects, CzechMap, ReadingList)
|
||||
is Book -> listOf(Subjects, CzechMap, ReadingList, this)
|
||||
is BookQuiz -> listOf(Subjects, CzechMap, ReadingList, Book(id), this)
|
||||
is BookPlot -> listOf(Subjects, CzechMap, ReadingList, Book(id), this)
|
||||
}
|
||||
}
|
||||
|
||||
fun routeFromPage(page: String): Route? = when (page) {
|
||||
"welcome" -> Route.Welcome
|
||||
"subjects" -> Route.Subjects
|
||||
"stats" -> Route.Stats
|
||||
"settings" -> Route.Settings
|
||||
"roadmap" -> Route.Roadmap
|
||||
"netyears" -> Route.NetYears
|
||||
"netmap" -> Route.NetMap
|
||||
|
|
|
|||
|
|
@ -36,16 +36,48 @@ data class Palette(
|
|||
val finishDark: Color,
|
||||
val finishLight: Color,
|
||||
val finishStroke: Color,
|
||||
/** Accent that stays readable as text and icon tint on [base]. */
|
||||
val tint: Color,
|
||||
/** Text colour that reads on [tint]. */
|
||||
val onTint: Color,
|
||||
)
|
||||
|
||||
private fun hx(v: Long) = Color(0xFF000000L or v)
|
||||
|
||||
private fun pal(vararg c: Long) = Palette(
|
||||
private fun luminance(v: Long): Double {
|
||||
fun channel(c: Long): Double {
|
||||
val s = c / 255.0
|
||||
return if (s <= 0.03928) s / 12.92 else Math.pow((s + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * channel((v shr 16) and 0xFF) + 0.7152 * channel((v shr 8) and 0xFF) + 0.0722 * channel(v and 0xFF)
|
||||
}
|
||||
|
||||
private fun contrast(a: Long, b: Long): Double {
|
||||
val la = luminance(a)
|
||||
val lb = luminance(b)
|
||||
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
|
||||
}
|
||||
|
||||
/** Yellow on white is unreadable as link text; fall back to the next accent. */
|
||||
private fun readableTint(c: LongArray): Long {
|
||||
val background = c[1]
|
||||
for (candidate in longArrayOf(c[9], c[10], c[11])) {
|
||||
if (contrast(candidate, background) >= 3.0) return candidate
|
||||
}
|
||||
return c[7]
|
||||
}
|
||||
|
||||
private fun pal(vararg c: Long): Palette {
|
||||
val tint = readableTint(c)
|
||||
return Palette(
|
||||
hx(c[0]), hx(c[1]), hx(c[2]), hx(c[3]), hx(c[4]), hx(c[5]), hx(c[6]),
|
||||
hx(c[7]), hx(c[8]), hx(c[9]), hx(c[10]), hx(c[11]), hx(c[12]), hx(c[13]),
|
||||
hx(c[14]), hx(c[15]), hx(c[16]), hx(c[17]), hx(c[18]), hx(c[19]), hx(c[20]),
|
||||
hx(c[21]), hx(c[22]), hx(c[23]),
|
||||
)
|
||||
tint = hx(tint),
|
||||
onTint = if (luminance(tint) > 0.4) Color(0xFF111111) else Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
fun themePalette(id: ThemeId, mode: ColorMode): Palette {
|
||||
val light = mode == ColorMode.Light
|
||||
|
|
|
|||
|
|
@ -1,38 +1,77 @@
|
|||
package org.maturita.maturita.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontSynthesis
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.maturita.maturita.AppViewModel
|
||||
import org.maturita.maturita.data.Palette
|
||||
import org.maturita.maturita.Route
|
||||
|
||||
/** Bold is capped at Medium: synthetic bold doubled glyphs on some devices. */
|
||||
fun appTextStyle(
|
||||
fontSize: TextUnit,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
|
|
@ -46,186 +85,379 @@ fun appTextStyle(
|
|||
fontWeight = weight,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontSynthesis = FontSynthesis.None,
|
||||
lineHeight = fontSize * 1.4f,
|
||||
lineHeight = fontSize * 1.35f,
|
||||
letterSpacing = 0.sp,
|
||||
textAlign = textAlign,
|
||||
)
|
||||
}
|
||||
|
||||
fun appTypography(): Typography {
|
||||
val base = appTextStyle(16.sp)
|
||||
return Typography(
|
||||
bodyLarge = base,
|
||||
fun appTypography(): Typography = Typography(
|
||||
displaySmall = appTextStyle(34.sp, FontWeight.Medium),
|
||||
headlineLarge = appTextStyle(30.sp, FontWeight.Medium),
|
||||
headlineMedium = appTextStyle(26.sp, FontWeight.Medium),
|
||||
headlineSmall = appTextStyle(22.sp, FontWeight.Medium),
|
||||
titleLarge = appTextStyle(20.sp, FontWeight.Medium),
|
||||
titleMedium = appTextStyle(16.sp, FontWeight.Medium),
|
||||
titleSmall = appTextStyle(14.sp, FontWeight.Medium),
|
||||
bodyLarge = appTextStyle(16.sp),
|
||||
bodyMedium = appTextStyle(14.sp),
|
||||
bodySmall = appTextStyle(12.sp),
|
||||
titleLarge = appTextStyle(22.sp, FontWeight.Medium),
|
||||
titleMedium = appTextStyle(18.sp, FontWeight.Medium),
|
||||
titleSmall = appTextStyle(16.sp, FontWeight.Medium),
|
||||
labelLarge = appTextStyle(14.sp, FontWeight.Medium),
|
||||
labelMedium = appTextStyle(12.sp, FontWeight.Medium),
|
||||
headlineMedium = appTextStyle(24.sp, FontWeight.Medium),
|
||||
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) },
|
||||
)
|
||||
}
|
||||
|
||||
private val Pill = RoundedCornerShape(999.dp)
|
||||
private val Card = RoundedCornerShape(20.dp)
|
||||
|
||||
@Composable
|
||||
fun PrimaryButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(Pill)
|
||||
.background(palette.accent)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 22.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, style = appTextStyle(15.sp, FontWeight.Medium, color = palette.onAccent))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PillButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(Pill)
|
||||
.background(palette.text.copy(alpha = 0.06f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(label, style = appTextStyle(13.sp, FontWeight.Medium, color = palette.text))
|
||||
}
|
||||
}
|
||||
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 IconHit(onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
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 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),
|
||||
)
|
||||
}
|
||||
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,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
style = appTextStyle(14.sp, textAlign = TextAlign.Center, color = p.subtext),
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/** Feedback line plus the primary action, anchored at the bottom of an exercise. */
|
||||
@Composable
|
||||
fun ActionBar(
|
||||
label: String,
|
||||
feedback: String = "",
|
||||
kind: String = "",
|
||||
enabled: Boolean = true,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 2.dp) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
if (feedback.isNotEmpty()) {
|
||||
FeedbackLine(feedback, kind)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
leading?.invoke()
|
||||
Button(onClick = onClick, enabled = enabled, modifier = Modifier.weight(1f).height(50.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lists
|
||||
|
||||
@Composable
|
||||
fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** One navigable row: done/pending/locked state on the left, chevron on the right. */
|
||||
@Composable
|
||||
fun NavRow(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
done: Boolean = false,
|
||||
locked: Boolean = false,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
val enabled = !locked && onClick != null
|
||||
val textColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val rowModifier = if (enabled) Modifier.clickable(onClick = onClick!!) else Modifier
|
||||
ListItem(
|
||||
headlineContent = { Text(title, color = textColor) },
|
||||
supportingContent = subtitle?.takeIf { it.isNotBlank() }?.let {
|
||||
{ Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
},
|
||||
leadingContent = {
|
||||
when {
|
||||
leading != null -> leading()
|
||||
locked -> Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.outline)
|
||||
done -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
else -> Icon(Icons.Filled.RadioButtonUnchecked, null, tint = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
},
|
||||
trailingContent = if (enabled) {
|
||||
{ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = MaterialTheme.colorScheme.outline) }
|
||||
} else null,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = rowModifier,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exercise widgets
|
||||
|
||||
@Composable
|
||||
fun PrimaryButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Button(onClick = onClick, modifier = modifier) { Text(label) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardBox(palette: Palette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(Card)
|
||||
.background(palette.mantle)
|
||||
.border(1.dp, palette.text.copy(alpha = 0.07f), Card)
|
||||
.padding(20.dp),
|
||||
) { content() }
|
||||
fun PillButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
FilledTonalButton(onClick = onClick, modifier = modifier) { Text(label) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeedbackLine(text: String, kind: String, palette: Palette) {
|
||||
fun FeedbackLine(text: String, kind: String) {
|
||||
if (text.isEmpty()) return
|
||||
val color = when (kind) {
|
||||
"ok" -> palette.success
|
||||
"warn" -> palette.warning
|
||||
else -> palette.error
|
||||
"ok" -> MaterialTheme.colorScheme.primary
|
||||
"warn" -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
val icon = when (kind) {
|
||||
"ok" -> Icons.Filled.CheckCircle
|
||||
"warn" -> Icons.Filled.Warning
|
||||
else -> Icons.Filled.Cancel
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, tint = color, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text, color = color, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
Text(text, color = color, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Meaning(text: String, palette: Palette, visible: Boolean) {
|
||||
fun Meaning(text: String, visible: Boolean) {
|
||||
if (!visible || text.isEmpty()) return
|
||||
Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp))
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Hint(text: String) {
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Prompt(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp, bottom = 4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WordField(
|
||||
value: String,
|
||||
onValue: (String) -> Unit,
|
||||
palette: Palette,
|
||||
placeholder: String,
|
||||
mark: Boolean? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
numeric: Boolean = false,
|
||||
) {
|
||||
val border = when (mark) {
|
||||
true -> palette.success
|
||||
false -> palette.error
|
||||
null -> palette.text.copy(alpha = 0.12f)
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(palette.surface0)
|
||||
.border(1.dp, border, RoundedCornerShape(14.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (value.isEmpty()) Text(placeholder, color = palette.overlay, fontSize = 15.sp)
|
||||
BasicTextField(
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValue,
|
||||
textStyle = TextStyle(color = palette.text, fontSize = 15.sp),
|
||||
cursorBrush = SolidColor(palette.accent),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder) }) else null,
|
||||
singleLine = true,
|
||||
isError = mark == false,
|
||||
trailingIcon = when (mark) {
|
||||
true -> ({ Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) })
|
||||
false -> ({ Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error) })
|
||||
null -> null
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = if (numeric) KeyboardType.Decimal else KeyboardType.Text,
|
||||
),
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Tappable word in a bank or a built sentence. */
|
||||
@Composable
|
||||
fun Chip(text: String, onClick: () -> Unit) {
|
||||
SuggestionChip(onClick = onClick, label = { Text(text) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SegChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
|
||||
}
|
||||
|
||||
/** Multiple-choice answer with radio state and, after checking, a correct/wrong mark. */
|
||||
@Composable
|
||||
fun OptionRow(text: String, selected: Boolean, mark: Boolean?, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
val container = when (mark) {
|
||||
true -> MaterialTheme.colorScheme.primaryContainer
|
||||
false -> MaterialTheme.colorScheme.errorContainer
|
||||
null -> if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SegChip(label: String, selected: Boolean, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(Pill)
|
||||
.background(if (selected) palette.mantle else Color.Transparent)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
color = container,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
) {
|
||||
Text(label, color = palette.text, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
||||
Row(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
when (mark) {
|
||||
true -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
false -> Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error)
|
||||
null -> RadioButton(selected = selected, onClick = null)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop zone for word ordering; visible even without words. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun Hint(text: String, palette: Palette) {
|
||||
Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp))
|
||||
fun WordSlot(empty: Boolean, content: @Composable () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
) {
|
||||
FlowRow(
|
||||
Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (empty) Text("…", color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding(8.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bottom sheet with the word bank for combo/dialogue blanks. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Prompt(text: String, palette: Palette) {
|
||||
Text(text, color = palette.text, fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
fun WordPicker(title: String, pool: List<String>, onPick: (String?) -> Unit) {
|
||||
ModalBottomSheet(onDismissRequest = { onPick(null) }) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
items(pool.size) { i ->
|
||||
ListItem(
|
||||
headlineContent = { Text(pool[i]) },
|
||||
modifier = Modifier.clickable { onPick(pool[i]) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -51,6 +51,18 @@ fun StatsIcon(color: Color, iconSize: Dp = 18.dp) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SubjectsIcon(color: Color, iconSize: Dp = 22.dp) {
|
||||
DrawnIcon(iconSize) {
|
||||
val gap = size.minDimension * 0.08f
|
||||
val cell = (size.minDimension - gap) / 2f
|
||||
val r = androidx.compose.ui.geometry.CornerRadius(cell * 0.28f)
|
||||
for (iy in 0 until 2) for (ix in 0 until 2) {
|
||||
drawRoundRect(color, Offset(ix * (cell + gap), iy * (cell + gap)), Size(cell, cell), r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) {
|
||||
DrawnIcon(iconSize) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -926,7 +926,7 @@ flowboxchild {
|
|||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.stats-page {
|
||||
padding: 8px 28px 24px 28px;
|
||||
padding: 24px 32px 24px 32px;
|
||||
}
|
||||
.stats-summary {
|
||||
margin-top: 10px;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; };
|
||||
A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; };
|
||||
A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.swift */; };
|
||||
A101000000000000000012 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000012 /* PathMap.swift */; };
|
||||
A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; };
|
||||
A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; };
|
||||
A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.swift */; };
|
||||
|
|
@ -38,7 +37,6 @@
|
|||
A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = "<group>"; };
|
||||
A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -108,7 +106,6 @@
|
|||
children = (
|
||||
A201000000000000000010 /* Components.swift */,
|
||||
A201000000000000000011 /* Icons.swift */,
|
||||
A201000000000000000012 /* PathMap.swift */,
|
||||
A201000000000000000013 /* Screens.swift */,
|
||||
A201000000000000000014 /* Exercises.swift */,
|
||||
A201000000000000000015 /* ExercisesMore.swift */,
|
||||
|
|
@ -230,7 +227,6 @@
|
|||
A101000000000000000009 /* Updater.swift in Sources */,
|
||||
A101000000000000000010 /* Components.swift in Sources */,
|
||||
A101000000000000000011 /* Icons.swift in Sources */,
|
||||
A101000000000000000012 /* PathMap.swift in Sources */,
|
||||
A101000000000000000013 /* Screens.swift in Sources */,
|
||||
A101000000000000000014 /* Exercises.swift in Sources */,
|
||||
A101000000000000000015 /* ExercisesMore.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,18 @@ final class AppModel: ObservableObject {
|
|||
themeId = progress.themeId
|
||||
mode = progress.mode
|
||||
lang = progress.lang
|
||||
#if DEBUG
|
||||
// `xcrun simctl launch <udid> org.maturita.maturita -route u1e3` opens a page for screenshots.
|
||||
if let page = UserDefaults.standard.string(forKey: "route"), let r = routeFromPage(page) {
|
||||
openFromSearch(r.page)
|
||||
}
|
||||
if let theme = UserDefaults.standard.string(forKey: "theme"), let n = Int(theme), let id = ThemeId(rawValue: n) {
|
||||
themeId = id
|
||||
}
|
||||
if let m = UserDefaults.standard.string(forKey: "mode") {
|
||||
mode = m == "dark" ? .dark : .light
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var palette: Palette { themePalette(themeId, mode) }
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ struct Palette {
|
|||
let success, success2, warning, error: Color
|
||||
let onAccent, node, lockedBg, lockedBorder, rail: Color
|
||||
let finishDark, finishLight, finishStroke: Color
|
||||
/// Accent that stays readable as text and icon tint on the theme's background.
|
||||
let tint: Color
|
||||
}
|
||||
|
||||
private func hx(_ v: UInt32) -> Color {
|
||||
|
|
@ -34,6 +36,29 @@ private func hx(_ v: UInt32) -> Color {
|
|||
)
|
||||
}
|
||||
|
||||
private func luminance(_ v: UInt32) -> Double {
|
||||
func channel(_ c: UInt32) -> Double {
|
||||
let s = Double(c) / 255
|
||||
return s <= 0.03928 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * channel((v >> 16) & 0xFF) + 0.7152 * channel((v >> 8) & 0xFF) + 0.0722 * channel(v & 0xFF)
|
||||
}
|
||||
|
||||
private func contrast(_ a: UInt32, _ b: UInt32) -> Double {
|
||||
let la = luminance(a), lb = luminance(b)
|
||||
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
|
||||
}
|
||||
|
||||
/// Pick the first accent with enough contrast against the background; yellow on
|
||||
/// white is unreadable as tab labels and link text.
|
||||
private func readableTint(_ c: [UInt32]) -> UInt32 {
|
||||
let background = c[1]
|
||||
for candidate in [c[9], c[10], c[11]] where contrast(candidate, background) >= 3.0 {
|
||||
return candidate
|
||||
}
|
||||
return c[7]
|
||||
}
|
||||
|
||||
private func pal(_ c: [UInt32]) -> Palette {
|
||||
Palette(
|
||||
crust: hx(c[0]), base: hx(c[1]), mantle: hx(c[2]), surface0: hx(c[3]),
|
||||
|
|
@ -42,7 +67,8 @@ private func pal(_ c: [UInt32]) -> Palette {
|
|||
success: hx(c[12]), success2: hx(c[13]), warning: hx(c[14]), error: hx(c[15]),
|
||||
onAccent: hx(c[16]), node: hx(c[17]), lockedBg: hx(c[18]),
|
||||
lockedBorder: hx(c[19]), rail: hx(c[20]), finishDark: hx(c[21]),
|
||||
finishLight: hx(c[22]), finishStroke: hx(c[23])
|
||||
finishLight: hx(c[22]), finishStroke: hx(c[23]),
|
||||
tint: hx(readableTint(c))
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ struct NativeRoot: View {
|
|||
tabs
|
||||
}
|
||||
}
|
||||
.tint(vm.palette.accent)
|
||||
.tint(vm.palette.tint)
|
||||
.preferredColorScheme(vm.mode == .dark ? .dark : .light)
|
||||
.onAppear { vm.checkUpdate(false) }
|
||||
}
|
||||
|
|
@ -151,7 +151,7 @@ struct RouteDestination: View {
|
|||
case .mluvnice:
|
||||
LessonListScreen(
|
||||
title: vm.tr("Mluvnice"),
|
||||
subtitle: "Cvičení z mluvnice",
|
||||
subtitle: vm.tr("mluv_sub"),
|
||||
rows: vm.content.mluvnice.map { m in
|
||||
let n = m.int("id")
|
||||
return LessonRow(
|
||||
|
|
|
|||
|
|
@ -44,8 +44,12 @@ struct PrimaryButton: View {
|
|||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(label, action: action)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.body.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
|
@ -78,7 +82,11 @@ struct FeedbackLine: View {
|
|||
|
||||
var body: some View {
|
||||
if !text.isEmpty {
|
||||
Label {
|
||||
Text(text)
|
||||
} icon: {
|
||||
Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
|
||||
}
|
||||
|
|
@ -107,21 +115,28 @@ struct WordField: View {
|
|||
var palette: Palette?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
TextField(placeholder, text: $value)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.padding(12)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
if let mark {
|
||||
Image(systemName: mark ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(mark ? (palette?.success ?? .green) : (palette?.error ?? .red))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(border, lineWidth: mark == nil ? 0 : 1.5)
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(border, lineWidth: mark == nil ? 0.5 : 1.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var border: Color {
|
||||
if mark == true { return palette?.success ?? .green }
|
||||
if mark == false { return palette?.error ?? .red }
|
||||
return .clear
|
||||
return Color(.separator)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,10 +173,29 @@ struct WordChip: View {
|
|||
var body: some View {
|
||||
Button(text, action: action)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.primary)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop zone for word ordering: visible even when empty.
|
||||
struct WordSlot<Content: View>: View {
|
||||
let empty: Bool
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(style: StrokeStyle(lineWidth: empty ? 1 : 0, dash: [5, 4]))
|
||||
.foregroundStyle(.tertiary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionChip: View {
|
||||
let text: String
|
||||
let selected: Bool
|
||||
|
|
@ -171,15 +205,32 @@ struct OptionChip: View {
|
|||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(text).frame(maxWidth: .infinity, alignment: .leading)
|
||||
if selected { Image(systemName: "checkmark") }
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(symbolColor)
|
||||
Text(text)
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(tint)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
if mark == true { return "checkmark.circle.fill" }
|
||||
if mark == false { return "xmark.circle.fill" }
|
||||
return selected ? "largecircle.fill.circle" : "circle"
|
||||
}
|
||||
|
||||
private var symbolColor: Color {
|
||||
if mark == true { return .green }
|
||||
if mark == false { return .red }
|
||||
return selected ? .accentColor : .secondary
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
if mark == true { return .green }
|
||||
if mark == false { return .red }
|
||||
|
|
@ -196,6 +247,39 @@ extension View {
|
|||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail screens with their own bottom action hide the tab bar so the
|
||||
/// primary button is never covered by it.
|
||||
func detailScreen(_ title: String, subtitle: String? = nil) -> some View {
|
||||
navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navSubtitle(subtitle)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
}
|
||||
|
||||
/// Full-width, leading-aligned content for exercise screens.
|
||||
func exerciseContent() -> some View {
|
||||
frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom action area used by exercises and lesson slides.
|
||||
struct BottomAction<Extra: View>: View {
|
||||
let label: String
|
||||
var action: () -> Void
|
||||
@ViewBuilder var extra: () -> Extra
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
extra()
|
||||
PrimaryButton(label: label, action: action)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
func subjectSymbol(_ icon: String) -> String {
|
||||
|
|
@ -207,3 +291,24 @@ func subjectSymbol(_ icon: String) -> String {
|
|||
default: return "lock.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Subject glyph for list rows: real flags for the languages, SF Symbols otherwise.
|
||||
struct SubjectIcon: View {
|
||||
let icon: String
|
||||
var open = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch icon {
|
||||
case "de": GermanFlag(size: 26)
|
||||
case "cz": CzechFlag(size: 26)
|
||||
default:
|
||||
Image(systemName: subjectSymbol(icon))
|
||||
.font(.title3)
|
||||
.foregroundStyle(open ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary))
|
||||
}
|
||||
}
|
||||
.frame(width: 28, height: 28)
|
||||
.opacity(open ? 1 : 0.6)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,28 +14,20 @@ struct ExerciseScaffold<Content: View>: View {
|
|||
let p = vm.palette
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) { content() }
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navSubtitle(sub)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
Button(action, action: onAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.exerciseContent()
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.detailScreen(title, subtitle: sub)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
BottomAction(label: action, action: onAction) {
|
||||
if !feedback.isEmpty {
|
||||
FeedbackLine(text: feedback, kind: kind, palette: p)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.default, value: feedback)
|
||||
}
|
||||
}
|
||||
|
||||
struct GermanExercise: View {
|
||||
|
|
@ -102,7 +94,7 @@ struct DispatchExercise: View {
|
|||
case "letters", "letters_gap": LettersEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "table": TableEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "reveal": RevealEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
default: Text("?").foregroundStyle(vm.palette.text)
|
||||
default: Text("?").foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,8 +131,7 @@ private struct ChoiceEx: View {
|
|||
}, feedback: fb.0, kind: fb.1) {
|
||||
ForEach(qs.indices, id: \.self) { i in
|
||||
Text("\(i + 1). \(qs[i].str("prompt"))")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(p.text)
|
||||
.font(.body.weight(.semibold))
|
||||
.padding(.bottom, 6)
|
||||
ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in
|
||||
let sel = picks[i] == o
|
||||
|
|
@ -196,8 +187,8 @@ private struct AssemblyEx: View {
|
|||
}, feedback: fb.0, kind: fb.1) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt) }
|
||||
WordSlot(empty: placed[i].isEmpty) {
|
||||
FlowLayout {
|
||||
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) }
|
||||
ForEach(placed[i], id: \.self) { idx in
|
||||
if idx < items[i].strs("words").count {
|
||||
WordChip(text: items[i].strs("words")[idx]) {
|
||||
|
|
@ -207,9 +198,7 @@ private struct AssemblyEx: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(pools[i], id: \.self) { idx in
|
||||
|
|
@ -395,7 +384,7 @@ private struct AssignEx: View {
|
|||
SegChip(label: groups[i], selected: active == i) { active = i }
|
||||
}
|
||||
}
|
||||
Text(vm.tr("wordbank")).font(.system(size: 12)).foregroundStyle(p.subtext).padding(.top, 10)
|
||||
Text(vm.tr("wordbank")).font(.caption).foregroundStyle(.secondary).padding(.top, 10)
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
if loc[i] == -1 {
|
||||
|
|
@ -405,7 +394,7 @@ private struct AssignEx: View {
|
|||
}
|
||||
}
|
||||
ForEach(groups.indices, id: \.self) { g in
|
||||
Text(groups[g]).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 10)
|
||||
Text(groups[g]).font(.caption.weight(.semibold)).foregroundStyle(.secondary).padding(.top, 10)
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
if loc[i] == g {
|
||||
|
|
@ -451,6 +440,7 @@ private struct HangmanEx: View {
|
|||
let letters = spec.strs("letters")
|
||||
let p = vm.palette
|
||||
let cur = word < words.count ? words[word].uppercased() : ""
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(.secondary)
|
||||
if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))") }
|
||||
|
|
@ -461,30 +451,30 @@ private struct HangmanEx: View {
|
|||
gallows.move(to: CGPoint(x: 70, y: size.height - 10))
|
||||
gallows.addLine(to: CGPoint(x: 70, y: 16))
|
||||
gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, y: 16))
|
||||
ctx.stroke(gallows, with: .color(p.text), style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
||||
ctx.stroke(gallows, with: .color(.primary), style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
||||
let x = size.width * 0.3 + 40
|
||||
if misses > 0 {
|
||||
var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36))
|
||||
ctx.stroke(rope, with: .color(p.text), lineWidth: 4)
|
||||
ctx.stroke(rope, with: .color(.primary), lineWidth: 4)
|
||||
}
|
||||
if misses > 1 {
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(p.text), lineWidth: 4)
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(.primary), lineWidth: 4)
|
||||
}
|
||||
if misses > 2 {
|
||||
var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110))
|
||||
ctx.stroke(body, with: .color(p.text), lineWidth: 4)
|
||||
ctx.stroke(body, with: .color(.primary), lineWidth: 4)
|
||||
}
|
||||
if misses > 3 {
|
||||
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100))
|
||||
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
|
||||
ctx.stroke(a, with: .color(.primary), lineWidth: 4)
|
||||
}
|
||||
if misses > 4 {
|
||||
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100))
|
||||
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
|
||||
ctx.stroke(a, with: .color(.primary), lineWidth: 4)
|
||||
}
|
||||
if misses > 5 {
|
||||
var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140))
|
||||
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
|
||||
ctx.stroke(a, with: .color(.primary), lineWidth: 4)
|
||||
}
|
||||
}
|
||||
.frame(height: 160)
|
||||
|
|
@ -493,13 +483,14 @@ private struct HangmanEx: View {
|
|||
let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
|
||||
let show = idx.map { guessed[$0] } ?? false
|
||||
Text(show ? String(ch) : "_")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(p.text)
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.monospaced()
|
||||
}
|
||||
}
|
||||
ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in
|
||||
HStack(spacing: 4) {
|
||||
ForEach(row, id: \.self) { letter in
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(letters, id: \.self) { letter in
|
||||
if let i = letters.firstIndex(of: letter) {
|
||||
Button {
|
||||
guessed[i] = true
|
||||
|
|
@ -523,18 +514,15 @@ private struct HangmanEx: View {
|
|||
}
|
||||
} label: {
|
||||
Text(letter)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(p.text)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(guessed[i] ? p.surface1 : p.mantle, in: RoundedRectangle(cornerRadius: 8))
|
||||
.font(.body.weight(.semibold))
|
||||
.frame(minWidth: 22)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(guessed[i] ? .secondary : .primary)
|
||||
.disabled(guessed[i] || finished)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
|
||||
if misses >= 6 && !finished {
|
||||
PillButton(label: vm.tr("hm_retry")) {
|
||||
|
|
@ -543,12 +531,10 @@ private struct HangmanEx: View {
|
|||
fb = ("", "")
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navSubtitle(sub)
|
||||
.exerciseContent()
|
||||
}
|
||||
.detailScreen(title, subtitle: sub)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -586,23 +572,55 @@ private struct VocabEx: View {
|
|||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(vm.fmt("trans_progress", done, total)).foregroundStyle(.secondary)
|
||||
ProgressView(value: Double(done), total: Double(max(total, 1)))
|
||||
Text(vm.fmt("trans_progress", done, total)).font(.footnote).foregroundStyle(.secondary)
|
||||
if cards.isEmpty {
|
||||
Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success)
|
||||
Label(vm.tr("trans_done"), systemImage: "checkmark.seal.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(p.success)
|
||||
.padding(.top, 8)
|
||||
} else if index < cards.count {
|
||||
let cur = cards[index]
|
||||
Hint(text: cur.0)
|
||||
Prompt(text: cur.1)
|
||||
GlassCard {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(cur.0).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Text(cur.1).font(.title2.weight(.semibold))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) }
|
||||
WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p)
|
||||
PrimaryButton(label: vm.tr("check")) {
|
||||
.submitLabel(.done)
|
||||
.onSubmit { check(cur) }
|
||||
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
|
||||
}
|
||||
}
|
||||
.exerciseContent()
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.detailScreen(title, subtitle: sub)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if index < cards.count {
|
||||
BottomAction(label: vm.tr("check"), action: { check(cards[index]) }) { EmptyView() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func check(_ cur: (String, String, String)) {
|
||||
let norm = normalizeAnswer(value)
|
||||
if answerAccepts(norm, cur.2) {
|
||||
done += 1
|
||||
cards.remove(at: index)
|
||||
if cards.isEmpty { fb = (vm.tr("trans_done"), "ok"); onDone() }
|
||||
else { if index >= cards.count { index = 0 }; value = ""; fb = ("", "") }
|
||||
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 {
|
||||
|
|
@ -613,13 +631,4 @@ private struct VocabEx: View {
|
|||
value = ""
|
||||
}
|
||||
}
|
||||
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navSubtitle(sub)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ struct DialogEx: View {
|
|||
let line = lines[i]
|
||||
if let name = line.name {
|
||||
Text(name)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(vm.palette.subtext)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
ComboLine(
|
||||
|
|
@ -68,7 +68,7 @@ struct ComboEx: View {
|
|||
ComboRows(vm: vm, title: title, sub: sub, pool: spec.strs("pool"), n: allRows.count, onDone: onDone, revealMean: spec.bool("transUpfront")) { picks, expected, revealed in
|
||||
ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in
|
||||
if let title = line.title {
|
||||
Text(title).font(.system(size: 15, weight: .semibold)).foregroundStyle(vm.palette.subtext)
|
||||
Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(.secondary)
|
||||
}
|
||||
ComboLine(
|
||||
vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx,
|
||||
|
|
@ -92,18 +92,18 @@ struct ComboEx: View {
|
|||
ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
|
||||
FlowLayout(spacing: 4) {
|
||||
if let num = block.num {
|
||||
Text(num).font(.system(size: 16, weight: .semibold)).foregroundStyle(vm.palette.text)
|
||||
Text(num).font(.body.weight(.semibold)).foregroundStyle(.primary)
|
||||
}
|
||||
ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
|
||||
switch part {
|
||||
case .text(let s):
|
||||
Text(s).foregroundStyle(vm.palette.text)
|
||||
Text(s).foregroundStyle(.primary)
|
||||
case .blank(let idx, let ans):
|
||||
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
|
||||
}
|
||||
}
|
||||
if let mean = block.meaning {
|
||||
Text(vm.tr(mean)).font(.system(size: 12)).foregroundStyle(vm.palette.subtext)
|
||||
Text(vm.tr(mean)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
|
|
@ -267,35 +267,36 @@ private struct ComboLine: View {
|
|||
let chip = Button {
|
||||
open = true
|
||||
} label: {
|
||||
Text(pick.isEmpty ? "—" : pick)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(pick.isEmpty ? p.overlay : p.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(p.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(mark == true ? p.success : mark == false ? p.error : p.text.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
HStack(spacing: 4) {
|
||||
Text(pick.isEmpty ? "…" : pick)
|
||||
.foregroundStyle(pick.isEmpty ? Color.secondary : Color.primary)
|
||||
.frame(minWidth: 36)
|
||||
Image(systemName: mark == true ? "checkmark.circle.fill" : mark == false ? "xmark.circle.fill" : "chevron.up.chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(mark == true ? p.success : mark == false ? p.error : Color.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.tint(mark == true ? p.success : mark == false ? p.error : .secondary)
|
||||
|
||||
Group {
|
||||
if inline {
|
||||
chip
|
||||
} else {
|
||||
FlowLayout(spacing: 6) {
|
||||
if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) }
|
||||
if !prefix.isEmpty { Text(prefix) }
|
||||
chip
|
||||
if !suffix.isEmpty { Text(suffix).font(.system(size: 16)).foregroundStyle(p.text) }
|
||||
if !suffix.isEmpty { Text(suffix) }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if idx >= 0 && idx < expected.count { expected[idx] = answer }
|
||||
}
|
||||
.onChange(of: answer) { new in
|
||||
.onChange(of: answer) { _, new in
|
||||
if idx >= 0 && idx < expected.count { expected[idx] = new }
|
||||
}
|
||||
.sheet(isPresented: $open) {
|
||||
|
|
@ -319,16 +320,22 @@ struct WordPicker: View {
|
|||
var body: some View {
|
||||
NavigationStack {
|
||||
List(Array(pool.enumerated()), id: \.offset) { _, w in
|
||||
Button(w) { onPick(w) }
|
||||
Button {
|
||||
onPick(w)
|
||||
} label: {
|
||||
Text(w).foregroundStyle(.primary)
|
||||
}
|
||||
.navigationTitle("")
|
||||
}
|
||||
.navigationTitle("Vyberte slovo")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Zavřít", role: .cancel) { onPick(nil) }
|
||||
Button(role: .cancel) { onPick(nil) } label: { Image(systemName: "xmark") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -351,12 +358,12 @@ struct Ex2Ex: View {
|
|||
ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in
|
||||
FlowLayout(spacing: 4) {
|
||||
if let num = row.num {
|
||||
Text(num).foregroundStyle(vm.palette.text).padding(.trailing, 6)
|
||||
Text(num).foregroundStyle(.primary).padding(.trailing, 6)
|
||||
}
|
||||
ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
|
||||
switch part {
|
||||
case .text(let s):
|
||||
Text(s).foregroundStyle(vm.palette.text)
|
||||
Text(s).foregroundStyle(.primary)
|
||||
case .blank(let idx, let ans):
|
||||
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
|
||||
}
|
||||
|
|
@ -415,7 +422,6 @@ struct LettersEx: View {
|
|||
|
||||
var body: some View {
|
||||
let rows = spec.arr("rows")
|
||||
let p = vm.palette
|
||||
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
|
||||
var i = 0, ok = 0, total = 0
|
||||
for r in rows {
|
||||
|
|
@ -449,12 +455,12 @@ private struct LettersRows: View {
|
|||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(pairs.indices, id: \.self) { ri in
|
||||
let row = pairs[ri]
|
||||
if let num = row.num { Text(num).foregroundStyle(p.subtext) }
|
||||
if let num = row.num { Text(num).foregroundStyle(.secondary) }
|
||||
FlowLayout {
|
||||
ForEach(row.parts.indices, id: \.self) { pi in
|
||||
switch row.parts[pi] {
|
||||
case .text(let s):
|
||||
Text(s).foregroundStyle(p.text)
|
||||
Text(s).foregroundStyle(.primary)
|
||||
case .field(let idx, let ans):
|
||||
WordField(
|
||||
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
|
||||
|
|
@ -539,10 +545,10 @@ struct TableEx: View {
|
|||
revealed = true
|
||||
}, feedback: fb.0, kind: fb.1) {
|
||||
ForEach(nouns.indices, id: \.self) { r in
|
||||
Text(nouns[r]).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
||||
Text(nouns[r]).font(.body.weight(.bold)).foregroundStyle(.primary)
|
||||
ForEach(persons.indices, id: \.self) { c in
|
||||
let i = r * persons.count + c
|
||||
Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext)
|
||||
Text(persons[c]).font(.caption).foregroundStyle(.secondary)
|
||||
WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), placeholder: "", palette: p)
|
||||
}
|
||||
Spacer().frame(height: 8)
|
||||
|
|
@ -658,29 +664,41 @@ private struct VlsmEx: View {
|
|||
let tasks = vm.content.netTasks
|
||||
let answers = vm.content.netAnswers
|
||||
let p = vm.palette
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(tasks.indices, id: \.self) { ti in
|
||||
let task = tasks[ti]
|
||||
let block = ti < answers.count ? answers[ti] : []
|
||||
let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 }
|
||||
Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8)
|
||||
GlassCard {
|
||||
Label {
|
||||
Text(task.str("prompt"))
|
||||
} icon: {
|
||||
Image(systemName: done[ti] ? "checkmark.circle.fill" : "\(ti + 1).circle")
|
||||
.foregroundStyle(done[ti] ? p.success : .secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
if infeasible {
|
||||
Hint(text: vm.tr("net_infeasible"))
|
||||
} else {
|
||||
ForEach(block.indices, id: \.self) { si in
|
||||
Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(p.subtext)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 6)
|
||||
PrefixMenu(value: Binding(
|
||||
get: { rows[ti][si].pfx },
|
||||
set: { rows[ti][si].pfx = $0 }
|
||||
), palette: p)
|
||||
WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), placeholder: "síť", palette: p)
|
||||
.keyboardType(.decimalPad)
|
||||
WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), placeholder: "broadcast", palette: p)
|
||||
.keyboardType(.decimalPad)
|
||||
WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), placeholder: "první uzel", palette: p)
|
||||
.keyboardType(.decimalPad)
|
||||
WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), placeholder: "poslední uzel", palette: p)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
PillButton(label: vm.tr("check")) {
|
||||
let ok = block.indices.allSatisfy { si in
|
||||
|
|
@ -717,14 +735,22 @@ private struct VlsmEx: View {
|
|||
maybeFinish()
|
||||
}
|
||||
if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) }
|
||||
Divider().padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.exerciseContent()
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.detailScreen(vm.tr("net_ex_title"))
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if !fb.0.isEmpty {
|
||||
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle(vm.tr("net_ex_title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
.animation(.default, value: fb.0)
|
||||
}
|
||||
|
||||
private func maybeFinish() {
|
||||
|
|
@ -739,15 +765,17 @@ private struct PrefixMenu: View {
|
|||
|
||||
var body: some View {
|
||||
Button { open = true } label: {
|
||||
HStack {
|
||||
Text("Prefix").foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(value.isEmpty ? "/…" : value)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(palette.text)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.foregroundStyle(value.isEmpty ? Color.secondary : Color.primary)
|
||||
.monospacedDigit()
|
||||
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
.sheet(isPresented: $open) {
|
||||
WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
|
||||
if let word { value = word }
|
||||
|
|
@ -765,63 +793,75 @@ struct LitQuizScreen: View {
|
|||
@State private var score = 0
|
||||
@State private var answered = false
|
||||
@State private var finished = false
|
||||
@State private var picked = -1
|
||||
|
||||
var body: some View {
|
||||
if let book = vm.content.book(id) {
|
||||
let qs = book.arr("quiz")
|
||||
let p = vm.palette
|
||||
let kahoot = [p.accent, p.error, p.warning, p.success]
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if finished {
|
||||
Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
|
||||
Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext)
|
||||
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
||||
PrimaryButton(label: vm.tr("lit_again")) {
|
||||
idx = 0; score = 0; answered = false; finished = false
|
||||
GlassCard {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label(vm.tr("lit_finished"), systemImage: "rosette")
|
||||
.font(.title2.weight(.bold))
|
||||
Text(vm.tr("lit_finished_text")).foregroundStyle(.secondary)
|
||||
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.headline)
|
||||
}
|
||||
}
|
||||
} else if idx < qs.count {
|
||||
let q = qs[idx]
|
||||
ProgressView(value: Double(idx), total: Double(qs.count))
|
||||
HStack {
|
||||
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)).foregroundStyle(p.subtext)
|
||||
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count))
|
||||
Spacer()
|
||||
Text(vm.fmt("lit_score_fmt", score, qs.count)).foregroundStyle(p.subtext)
|
||||
Text(vm.fmt("lit_score_fmt", score, qs.count))
|
||||
}
|
||||
Text(q.str("prompt")).font(.system(size: 18, weight: .semibold)).foregroundStyle(p.text).padding(.vertical, 12)
|
||||
.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
|
||||
Button {
|
||||
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 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))
|
||||
if correct { score += 1 }
|
||||
}
|
||||
.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")) {
|
||||
}
|
||||
}
|
||||
}
|
||||
.exerciseContent()
|
||||
}
|
||||
.detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if finished {
|
||||
BottomAction(label: vm.tr("lit_again"), action: {
|
||||
idx = 0; score = 0; answered = false; finished = false; picked = -1
|
||||
}) { EmptyView() }
|
||||
} else if answered {
|
||||
BottomAction(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), action: {
|
||||
if idx + 1 >= qs.count {
|
||||
finished = true
|
||||
vm.markBookQuiz(id)
|
||||
} else {
|
||||
idx += 1
|
||||
answered = false
|
||||
picked = -1
|
||||
}
|
||||
}) { EmptyView() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle(vm.tr(book.str("quizTitle")))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationSubtitle(vm.tr(book.str("quizSub")))
|
||||
.animation(.default, value: answered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,166 +1,6 @@
|
|||
import SwiftUI
|
||||
|
||||
struct BackIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 16
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.18))
|
||||
p.addLine(to: CGPoint(x: sz.width * 0.28, y: sz.height * 0.5))
|
||||
p.addLine(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.82))
|
||||
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 18
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let r = min(sz.width, sz.height) * 0.28
|
||||
let c = CGPoint(x: sz.width * 0.42, y: sz.height * 0.42)
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.12)
|
||||
var handle = Path()
|
||||
handle.move(to: CGPoint(x: sz.width * 0.62, y: sz.height * 0.62))
|
||||
handle.addLine(to: CGPoint(x: sz.width * 0.82, y: sz.height * 0.82))
|
||||
ctx.stroke(handle, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.12, lineCap: .round))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatsIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 18
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let w = min(sz.width, sz.height) * 0.16
|
||||
let bars: [(CGFloat, CGFloat, CGFloat)] = [
|
||||
(0.18, 0.48, 0.36), (0.42, 0.22, 0.62), (0.66, 0.36, 0.48),
|
||||
]
|
||||
for b in bars {
|
||||
let rect = CGRect(x: sz.width * b.0, y: sz.height * b.1, width: w, height: sz.height * b.2)
|
||||
ctx.fill(Path(roundedRect: rect, cornerRadius: w), with: .color(color))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 18
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let cx = sz.width / 2
|
||||
let cy = sz.height / 2
|
||||
let s = min(sz.width, sz.height)
|
||||
let rOuter = s * 0.48
|
||||
let rInner = s * 0.34
|
||||
let rHole = s * 0.16
|
||||
let teeth = 8
|
||||
let toothHalf = CGFloat.pi / CGFloat(teeth)
|
||||
var gear = Path()
|
||||
for i in 0..<teeth {
|
||||
let a = -CGFloat.pi / 2 + 2 * CGFloat.pi * CGFloat(i) / CGFloat(teeth)
|
||||
let a0 = a - toothHalf * 0.55
|
||||
let a1 = a - toothHalf * 0.28
|
||||
let a2 = a + toothHalf * 0.28
|
||||
let a3 = a + toothHalf * 0.55
|
||||
let p0 = CGPoint(x: cx + cos(a0) * rInner, y: cy + sin(a0) * rInner)
|
||||
if i == 0 { gear.move(to: p0) } else { gear.addLine(to: p0) }
|
||||
gear.addLine(to: CGPoint(x: cx + cos(a1) * rOuter, y: cy + sin(a1) * rOuter))
|
||||
gear.addLine(to: CGPoint(x: cx + cos(a2) * rOuter, y: cy + sin(a2) * rOuter))
|
||||
gear.addLine(to: CGPoint(x: cx + cos(a3) * rInner, y: cy + sin(a3) * rInner))
|
||||
}
|
||||
gear.closeSubpath()
|
||||
gear.addEllipse(in: CGRect(x: cx - rHole, y: cy - rHole, width: rHole * 2, height: rHole * 2))
|
||||
ctx.fill(gear, with: .color(color), style: FillStyle(eoFill: true))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct LockIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 18
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let body = CGRect(x: sz.width * 0.28, y: sz.height * 0.46, width: sz.width * 0.44, height: sz.height * 0.36)
|
||||
ctx.fill(Path(roundedRect: body, cornerRadius: min(sz.width, sz.height) * 0.08), with: .color(color))
|
||||
var arc = Path()
|
||||
arc.addArc(
|
||||
center: CGPoint(x: sz.width * 0.5, y: sz.height * 0.38),
|
||||
radius: sz.width * 0.18,
|
||||
startAngle: .degrees(200),
|
||||
endAngle: .degrees(340),
|
||||
clockwise: false
|
||||
)
|
||||
ctx.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.1, lineCap: .round))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 16
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: sz.width * 0.18, y: sz.height * 0.52))
|
||||
p.addLine(to: CGPoint(x: sz.width * 0.4, y: sz.height * 0.74))
|
||||
p.addLine(to: CGPoint(x: sz.width * 0.84, y: sz.height * 0.26))
|
||||
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct WifiIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 28
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let c = CGPoint(x: sz.width / 2, y: sz.height * 0.72)
|
||||
ctx.fill(Path(ellipseIn: CGRect(x: c.x - sz.width * 0.06, y: c.y - sz.width * 0.06, width: sz.width * 0.12, height: sz.width * 0.12)), with: .color(color))
|
||||
for i in 1...3 {
|
||||
let r = min(sz.width, sz.height) * (0.16 + CGFloat(i) * 0.16)
|
||||
var p = Path()
|
||||
p.addArc(center: c, radius: r, startAngle: .degrees(210), endAngle: .degrees(330), clockwise: false)
|
||||
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChipIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 28
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let m = min(sz.width, sz.height) * 0.22
|
||||
ctx.stroke(
|
||||
Path(roundedRect: CGRect(x: m, y: m, width: sz.width - 2 * m, height: sz.height - 2 * m), cornerRadius: 6),
|
||||
with: .color(color),
|
||||
lineWidth: min(sz.width, sz.height) * 0.08
|
||||
)
|
||||
let pin = min(sz.width, sz.height) * 0.08
|
||||
for i in 0..<3 {
|
||||
let t = m + (sz.height - 2 * m) * CGFloat(i + 1) / 4
|
||||
var l = Path(); l.move(to: CGPoint(x: m - pin, y: t)); l.addLine(to: CGPoint(x: m, y: t))
|
||||
var r = Path(); r.move(to: CGPoint(x: sz.width - m, y: t)); r.addLine(to: CGPoint(x: sz.width - m + pin, y: t))
|
||||
ctx.stroke(l, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
|
||||
ctx.stroke(r, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
// Circular flags used as subject glyphs; everything else is an SF Symbol.
|
||||
|
||||
struct GermanFlag: View {
|
||||
var size: CGFloat = 48
|
||||
|
|
@ -193,112 +33,3 @@ struct CzechFlag: View {
|
|||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
ctx.stroke(
|
||||
Path(roundedRect: CGRect(x: sz.width * 0.22, y: sz.height * 0.18, width: sz.width * 0.56, height: sz.height * 0.64), cornerRadius: 4),
|
||||
with: .color(color),
|
||||
lineWidth: min(sz.width, sz.height) * 0.08
|
||||
)
|
||||
var spine = Path()
|
||||
spine.move(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.18))
|
||||
spine.addLine(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.82))
|
||||
ctx.stroke(spine, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct QuizIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let r = min(sz.width, sz.height) * 0.36
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
|
||||
let d = min(sz.width, sz.height) * 0.12
|
||||
ctx.fill(Path(ellipseIn: CGRect(x: sz.width / 2 - d / 2, y: sz.height * 0.68 - d / 2, width: d, height: d)), with: .color(color))
|
||||
var stem = Path()
|
||||
stem.move(to: CGPoint(x: sz.width / 2, y: sz.height * 0.32))
|
||||
stem.addLine(to: CGPoint(x: sz.width / 2, y: sz.height * 0.52))
|
||||
ctx.stroke(stem, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct OrderIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
for i in 0..<3 {
|
||||
let y = sz.height * (0.28 + CGFloat(i) * 0.22)
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: sz.width * 0.22, y: y))
|
||||
p.addLine(to: CGPoint(x: sz.width * 0.78, y: y))
|
||||
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobeIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let r = min(sz.width, sz.height) * 0.34
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width * 0.32, y: sz.height * 0.18, width: sz.width * 0.36, height: sz.height * 0.64)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct PeopleIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let r = min(sz.width, sz.height) * 0.12
|
||||
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.35 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
|
||||
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.65 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
|
||||
var a = Path(); a.addArc(center: CGPoint(x: sz.width * 0.35, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
|
||||
var b = Path(); b.addArc(center: CGPoint(x: sz.width * 0.65, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
|
||||
ctx.stroke(a, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
ctx.stroke(b, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct BulbIcon: View {
|
||||
var color: Color
|
||||
var size: CGFloat = 22
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let r = min(sz.width, sz.height) * 0.22
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height * 0.4 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
|
||||
ctx.fill(Path(roundedRect: CGRect(x: sz.width * 0.4, y: sz.height * 0.58, width: sz.width * 0.2, height: sz.height * 0.2), cornerRadius: 3), with: .color(color))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func noteIcon(_ name: String, _ color: Color) -> some View {
|
||||
switch name {
|
||||
case "globe": GlobeIcon(color: color)
|
||||
case "people": PeopleIcon(color: color)
|
||||
case "bulb": BulbIcon(color: color)
|
||||
case "quiz": QuizIcon(color: color)
|
||||
case "order": OrderIcon(color: color)
|
||||
default: BookIcon(color: color)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ struct PracticeHome: View {
|
|||
}
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: subjectSymbol(s.str("icon")))
|
||||
SubjectIcon(icon: s.str("icon"), open: s.bool("open"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -77,11 +77,11 @@ struct SettingsScreen: View {
|
|||
vm.setTheme(id)
|
||||
} label: {
|
||||
HStack {
|
||||
Circle().fill(themePalette(id, vm.mode).accent).frame(width: 14, height: 14)
|
||||
Text(themeNames[id.rawValue])
|
||||
Circle().fill(themePalette(id, vm.mode).tint).frame(width: 14, height: 14)
|
||||
Text(themeNames[id.rawValue]).foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if vm.themeId == id {
|
||||
Image(systemName: "checkmark")
|
||||
Image(systemName: "checkmark").fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -130,28 +130,42 @@ struct StatsScreen: View {
|
|||
let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
|
||||
List {
|
||||
Section {
|
||||
HStack {
|
||||
GlassCard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
|
||||
stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
|
||||
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
ProgressView(value: pct)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
Section(vm.tr("stats_section")) {
|
||||
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
|
||||
let open = s.bool("open")
|
||||
let part = subjectSum(vm, s)
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(vm.tr(s.str("key")))
|
||||
Spacer()
|
||||
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
if open && part.totalEx > 0 {
|
||||
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
|
||||
}
|
||||
}
|
||||
.opacity(open ? 1 : 0.5)
|
||||
} icon: {
|
||||
SubjectIcon(icon: s.str("icon"), open: open)
|
||||
}
|
||||
.foregroundStyle(open ? .primary : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -159,9 +173,9 @@ struct StatsScreen: View {
|
|||
}
|
||||
|
||||
private func stat(_ label: String, _ value: String) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
Text(value).font(.title3.bold())
|
||||
Text(value).font(.title3.bold()).monospacedDigit()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
|
@ -397,31 +411,61 @@ struct SlidesScreen: View {
|
|||
.foregroundStyle(.secondary)
|
||||
Text(slide.str("title")).font(.title2.bold())
|
||||
if let tip = slide.strOrNull("tip") {
|
||||
GlassCard { Text(tip) }
|
||||
GlassCard {
|
||||
Label(tip, systemImage: "lightbulb.fill")
|
||||
}
|
||||
}
|
||||
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
|
||||
Text(line).padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(.bottom, 24)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.tag(i)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .automatic))
|
||||
.navigationTitle(title)
|
||||
.navigationSubtitle(subtitle)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.detailScreen(title, subtitle: subtitle)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(slides.indices, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(i == idx ? Color.accentColor : Color(.tertiarySystemFill))
|
||||
.frame(width: i == idx ? 18 : 6, height: 6)
|
||||
}
|
||||
}
|
||||
.animation(.default, value: idx)
|
||||
HStack(spacing: 10) {
|
||||
if idx > 0 {
|
||||
Button {
|
||||
withAnimation { idx -= 1 }
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.frame(minWidth: 24)
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
.controlSize(.large)
|
||||
}
|
||||
if idx < slides.count - 1 {
|
||||
Button(vm.tr("net_slide_next")) { idx += 1 }
|
||||
PrimaryButton(label: vm.tr("net_slide_next")) {
|
||||
withAnimation { idx += 1 }
|
||||
}
|
||||
} else {
|
||||
NavigationLink(vm.tr("net_slide_start"), value: next)
|
||||
PrimaryButton(label: vm.tr("net_slide_start")) {
|
||||
vm.go(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CzechMapScreen: View {
|
||||
|
|
|
|||
|
|
@ -225,6 +225,8 @@ const TrEntry tr_ui[] = {
|
|||
{"stats", "Statistiky", "Statistics"},
|
||||
{"stats_title", "Statistiky", "Statistics"},
|
||||
{"stats_sub", "Přehled vašeho pokroku.", "Overview of your progress."},
|
||||
{"mluv_sub", "Cvičení z mluvnice – styl maturita / přijímačky z ČJL",
|
||||
"Grammar exercises in the style of the maturita and entrance exams"},
|
||||
{"stats_ex_label", "Cvičení", "Exercises"},
|
||||
{"stats_pct_label", "Hotovo", "Done"},
|
||||
{"stats_units_label", "Jednotky", "Units"},
|
||||
|
|
|
|||
|
|
@ -61,8 +61,7 @@ void mluvnice_init(void) {
|
|||
mluv_unit.title = "Mluvnice";
|
||||
mluv_unit.page = "mluvnice";
|
||||
mluv_unit.ex_tag = "mluv";
|
||||
mluv_unit.sub_key = "Cvičení z mluvnice – styl maturita / "
|
||||
"přijímačky z ČJL";
|
||||
mluv_unit.sub_key = "mluv_sub";
|
||||
mluv_unit.back_target = "czechmap";
|
||||
mluv_unit.progress_file = app_progress_mluvnice;
|
||||
mluv_unit.n_ex = MLUV_N_EX;
|
||||
|
|
|
|||
10
src/net.c
10
src/net.c
|
|
@ -2669,7 +2669,10 @@ GtkWidget *net_qz_make_entry(void) {
|
|||
GtkWidget *e = gtk_entry_new();
|
||||
|
||||
gtk_widget_set_hexpand(e, TRUE);
|
||||
gtk_editable_set_width_chars(GTK_EDITABLE(e), 15);
|
||||
/* Four address columns must share the window; 15 chars each pushed the
|
||||
* last column past the right edge. */
|
||||
gtk_editable_set_width_chars(GTK_EDITABLE(e), 7);
|
||||
gtk_editable_set_max_width_chars(GTK_EDITABLE(e), 16);
|
||||
return e;
|
||||
}
|
||||
|
||||
|
|
@ -2688,8 +2691,10 @@ GtkWidget *build_net_exercise_page(void) {
|
|||
top_bar("netunit1", "net_ex_title", NULL));
|
||||
|
||||
scroll = gtk_scrolled_window_new();
|
||||
/* Never scroll sideways: the address table must shrink to the window
|
||||
* instead of pushing its last column off screen. */
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll),
|
||||
GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC);
|
||||
GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
|
||||
gtk_widget_set_vexpand(scroll, TRUE);
|
||||
gtk_widget_set_margin_top(scroll, 12);
|
||||
gtk_box_append(GTK_BOX(page), scroll);
|
||||
|
|
@ -2732,6 +2737,7 @@ GtkWidget *build_net_exercise_page(void) {
|
|||
gtk_grid_set_row_spacing(GTK_GRID(table), 6);
|
||||
gtk_grid_set_column_spacing(GTK_GRID(table), 8);
|
||||
gtk_widget_set_margin_top(table, 6);
|
||||
gtk_widget_set_hexpand(table, TRUE);
|
||||
gtk_box_append(GTK_BOX(task), table);
|
||||
|
||||
for (guint c = 1; c < 6; c++) {
|
||||
|
|
|
|||
|
|
@ -299,9 +299,11 @@ GtkWidget *build_stats_page(void) {
|
|||
outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_add_css_class(outer, "stats-page");
|
||||
|
||||
/* Same header rhythm as the other pages (top_bar). */
|
||||
top = gtk_center_box_new();
|
||||
gtk_widget_set_margin_top(top, 2);
|
||||
gtk_widget_set_margin_bottom(top, 6);
|
||||
gtk_widget_add_css_class(top, "page-top");
|
||||
gtk_widget_set_margin_top(top, 8);
|
||||
gtk_widget_set_margin_bottom(top, 18);
|
||||
gtk_box_append(GTK_BOX(outer), top);
|
||||
|
||||
back = make_back_button("subjects");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue