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