diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0abb597..af9820e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "org.maturita.maturita" minSdk = 26 targetSdk = 35 - versionCode = 5 - versionName = "android-5" + versionCode = 6 + versionName = "android-6" buildConfigField("String", "COMMIT", "\"$commit\"") buildConfigField("String", "UPDATE_REPO", "\"pepaskopec-blip/maturita.c\"") buildConfigField("String", "UPDATE_BRANCH", "\"builds\"") diff --git a/android/app/src/main/java/org/maturita/maturita/AppViewModel.kt b/android/app/src/main/java/org/maturita/maturita/AppViewModel.kt index 3148fa6..d9a9193 100644 --- a/android/app/src/main/java/org/maturita/maturita/AppViewModel.kt +++ b/android/app/src/main/java/org/maturita/maturita/AppViewModel.kt @@ -26,7 +26,6 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { private set var lang by mutableStateOf(progress.lang) private set - var settingsOpen by mutableStateOf(false) var searchOpen by mutableStateOf(false) var searchQuery by mutableStateOf("") var tick by mutableStateOf(0) @@ -34,19 +33,32 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { var update by mutableStateOf(UpdateState()) private set - private val stack = mutableStateListOf(Route.Welcome) + private val stack = mutableStateListOf(Route.Subjects) val route: Route get() = stack.last() - val canGoBack: Boolean get() = stack.size > 1 + val root: Route get() = stack.first() + val canGoBack: Boolean get() = stack.size > 1 || root != Route.Subjects val palette get() = themePalette(themeId, mode) fun tr(key: String?) = content.tr(key, lang) fun fmt(key: String, vararg args: Any) = content.fmt(key, lang, *args) + /** Roots switch the bottom tab; everything else is pushed on the practice stack. */ fun go(r: Route) { - if (stack.last() == r) return - stack.add(r) searchOpen = false + if (stack.last() == r) return + if (r.isRoot) { + val target = if (r == Route.Welcome) Route.Subjects else r + stack.clear() + stack.add(target) + return + } + if (root != Route.Subjects) { + stack.clear() + stack.addAll(r.stack()) + return + } + stack.add(r) } fun goPage(page: String?) { @@ -54,8 +66,25 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { go(r) } - fun back() { + /** Jumping from search rebuilds the whole path so back walks up level by level. */ + fun openPage(page: String) { + val r = routeFromPage(page) ?: return + searchOpen = false + stack.clear() + stack.addAll(r.stack()) + } + + /** Replace the current screen (lesson slides -> its exercise). */ + fun replace(r: Route) { if (stack.size > 1) stack.removeAt(stack.lastIndex) + stack.add(r) + } + + fun back() { + when { + stack.size > 1 -> stack.removeAt(stack.lastIndex) + root != Route.Subjects -> go(Route.Subjects) + } } fun setTheme(id: ThemeId) { diff --git a/android/app/src/main/java/org/maturita/maturita/MainActivity.kt b/android/app/src/main/java/org/maturita/maturita/MainActivity.kt index 50bf416..207521b 100644 --- a/android/app/src/main/java/org/maturita/maturita/MainActivity.kt +++ b/android/app/src/main/java/org/maturita/maturita/MainActivity.kt @@ -12,56 +12,39 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material3.Surface import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.Color +import org.maturita.maturita.data.ColorMode +import org.maturita.maturita.data.Palette import org.maturita.maturita.ui.BookListScreen import org.maturita.maturita.ui.BookScreen import org.maturita.maturita.ui.CzechMapScreen import org.maturita.maturita.ui.GermanExercise +import org.maturita.maturita.ui.HomeScreen import org.maturita.maturita.ui.HwQuizScreen -import org.maturita.maturita.ui.IconHit -import org.maturita.maturita.ui.LessonMapScreen +import org.maturita.maturita.ui.LessonListScreen +import org.maturita.maturita.ui.LessonRow import org.maturita.maturita.ui.LitQuizScreen -import org.maturita.maturita.ui.MapNode import org.maturita.maturita.ui.MluvExercise import org.maturita.maturita.ui.NetQuizScreen import org.maturita.maturita.ui.NetYearsScreen import org.maturita.maturita.ui.PlotScreen import org.maturita.maturita.ui.RoadmapScreen -import org.maturita.maturita.ui.SearchIcon -import org.maturita.maturita.ui.SearchOverlay -import org.maturita.maturita.ui.SettingsIcon -import org.maturita.maturita.ui.SettingsSheet +import org.maturita.maturita.ui.SearchScreen +import org.maturita.maturita.ui.SettingsScreen import org.maturita.maturita.ui.SlidesScreen -import org.maturita.maturita.ui.appTextStyle -import org.maturita.maturita.ui.appTypography -import org.maturita.maturita.ui.StatsIcon import org.maturita.maturita.ui.StatsScreen -import org.maturita.maturita.ui.SubjectsScreen import org.maturita.maturita.ui.UnitMapScreen -import org.maturita.maturita.data.ColorMode import org.maturita.maturita.ui.VocabExercise -import org.maturita.maturita.ui.WelcomeScreen +import org.maturita.maturita.ui.appTypography class MainActivity : ComponentActivity() { private val vm: AppViewModel by viewModels() @@ -125,165 +108,136 @@ class MainActivity : ComponentActivity() { } } +/** Material 3 colour roles from the shared theme palette. */ +private fun schemeFor(p: Palette, dark: Boolean) = if (dark) { + darkColorScheme( + primary = p.tint, + onPrimary = p.onTint, + primaryContainer = p.accent, + onPrimaryContainer = p.onAccent, + secondary = p.accent2, + onSecondary = p.onAccent, + secondaryContainer = p.surface1, + onSecondaryContainer = p.text, + tertiary = p.warning, + onTertiary = p.crust, + background = p.base, + onBackground = p.text, + surface = p.base, + onSurface = p.text, + surfaceVariant = p.surface0, + onSurfaceVariant = p.subtext, + surfaceContainerLowest = p.crust, + surfaceContainerLow = p.mantle, + surfaceContainer = p.mantle, + surfaceContainerHigh = p.surface0, + surfaceContainerHighest = p.surface1, + outline = p.overlay, + outlineVariant = p.surface1, + error = p.error, + onError = Color.White, + errorContainer = p.error.copy(alpha = 0.22f), + onErrorContainer = p.text, + ) +} else { + lightColorScheme( + primary = p.tint, + onPrimary = p.onTint, + primaryContainer = p.accent, + onPrimaryContainer = p.onAccent, + secondary = p.accent2, + onSecondary = Color.White, + secondaryContainer = p.surface1, + onSecondaryContainer = p.text, + tertiary = p.warning, + onTertiary = Color.White, + background = p.base, + onBackground = p.text, + surface = p.base, + onSurface = p.text, + surfaceVariant = p.surface1, + onSurfaceVariant = p.subtext, + surfaceContainerLowest = p.mantle, + surfaceContainerLow = p.mantle, + surfaceContainer = p.mantle, + surfaceContainerHigh = p.surface1, + surfaceContainerHighest = p.surface2, + outline = p.overlay, + outlineVariant = p.surface2, + error = p.error, + onError = Color.White, + errorContainer = p.error.copy(alpha = 0.14f), + onErrorContainer = p.text, + ) +} + @Composable fun MaturitaApp(vm: AppViewModel) { - val p = vm.palette - val scheme = if (vm.mode == ColorMode.Dark) { - darkColorScheme( - primary = p.accent, - onPrimary = p.onAccent, - secondary = p.accent2, - background = p.base, - onBackground = p.text, - surface = p.mantle, - onSurface = p.text, - surfaceVariant = p.surface1, - onSurfaceVariant = p.subtext, - error = p.error, - ) - } else { - lightColorScheme( - primary = p.accent, - onPrimary = p.onAccent, - secondary = p.accent2, - background = p.base, - onBackground = p.text, - surface = p.mantle, - onSurface = p.text, - surfaceVariant = p.surface1, - onSurfaceVariant = p.subtext, - error = p.error, - ) - } - MaterialTheme(colorScheme = scheme, typography = appTypography()) { - MaturitaAppBody(vm) + MaterialTheme(colorScheme = schemeFor(vm.palette, vm.mode == ColorMode.Dark), typography = appTypography()) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + MaturitaAppBody(vm) + } } } @Composable private fun MaturitaAppBody(vm: AppViewModel) { - val p = vm.palette LaunchedEffect(Unit) { vm.checkUpdate(false) } - BackHandler(vm.canGoBack || vm.searchOpen || vm.settingsOpen) { - when { - vm.searchOpen -> vm.searchOpen = false - vm.settingsOpen -> vm.settingsOpen = false - else -> vm.back() - } + BackHandler(vm.canGoBack || vm.searchOpen) { + if (vm.searchOpen) vm.searchOpen = false else vm.back() } - Box( - Modifier - .fillMaxSize() - .background(p.base) - .statusBarsPadding() - .navigationBarsPadding(), - ) { - Column(Modifier.fillMaxSize()) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text("maturita.c", style = appTextStyle(15.sp, FontWeight.Medium, color = p.text), modifier = Modifier.weight(1f)) - IconHit({ vm.searchOpen = true }) { SearchIcon(p.text) } - IconHit({ vm.go(Route.Stats) }) { StatsIcon(p.text) } - IconHit({ vm.settingsOpen = true }) { SettingsIcon(p.text) } - } - if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .clip(RoundedCornerShape(14.dp)) - .background(p.surface1) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())), - color = p.text, - fontSize = 13.sp, - modifier = Modifier.weight(1f), - ) - if (vm.update.canInstall) { - org.maturita.maturita.ui.PillButton(vm.tr("update_install"), p) { vm.installUpdate() } - } + Box(Modifier.fillMaxSize()) { + when (val r = vm.route) { + Route.Welcome, Route.Subjects -> HomeScreen(vm) + Route.Stats -> StatsScreen(vm) + Route.Settings -> SettingsScreen(vm) + Route.Roadmap -> RoadmapScreen(vm) + is Route.UnitMap -> UnitMapScreen(vm, r.unitId) + is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex) + is Route.Vocab -> VocabExercise(vm, r.unitId) + Route.NetYears -> NetYearsScreen(vm) + Route.NetMap -> LessonListScreen( + vm, vm.tr("net_year1"), vm.tr("net_sub"), + vm.content.netLessons.map { l -> + val id = l.int("id") + LessonRow(vm.tr(l.str("titleKey")), vm.progress.netDone(id), Route.NetLesson(id)) + }, + ) + is Route.NetLesson -> { + val l = vm.content.netLesson(r.id) + if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) { + vm.replace(Route.NetEx(r.id)) } } - Box(Modifier.weight(1f)) { - when (val r = vm.route) { - Route.Welcome -> WelcomeScreen(vm) - Route.Subjects -> SubjectsScreen(vm) - Route.Stats -> StatsScreen(vm) - Route.Roadmap -> RoadmapScreen(vm) - is Route.UnitMap -> UnitMapScreen(vm, r.unitId) - is Route.GermanEx -> GermanExercise(vm, r.unitId, r.ex) - is Route.Vocab -> VocabExercise(vm, r.unitId) - Route.NetYears -> NetYearsScreen(vm) - Route.NetMap -> LessonMapScreen( - vm, vm.tr("net_year1"), vm.tr("net_sub"), - vm.content.netLessons.map { l -> - val id = l.int("id") - MapNode( - "n$id", vm.tr(l.str("titleKey")), false, - vm.progress.netDone(id), !vm.progress.netDone(id), - ) { vm.go(Route.NetLesson(id)) } - }, - vm.content.netLessons.count { vm.progress.netDone(it.int("id")) }.toFloat(), - ) - is Route.NetLesson -> { - val l = vm.content.netLesson(r.id) - if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) { - vm.go(Route.NetEx(r.id)) - } - } - is Route.NetEx -> NetQuizScreen(vm, r.id) - Route.HwMap -> LessonMapScreen( - vm, vm.tr("Technické vybavení"), vm.tr("hw_sub"), - vm.content.hw.map { l -> - val id = l.int("id") - MapNode( - "h$id", vm.tr(l.str("titleKey")), false, - vm.progress.hwDone(id), !vm.progress.hwDone(id), - ) { vm.go(Route.HwLesson(id)) } - }, - vm.content.hw.count { vm.progress.hwDone(it.int("id")) }.toFloat(), - ) - is Route.HwLesson -> { - val l = vm.content.hwLesson(r.id) - if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) { - vm.go(Route.HwEx(r.id)) - } - } - is Route.HwEx -> HwQuizScreen(vm, r.id) - Route.CzechMap -> CzechMapScreen(vm) - Route.Mluvnice -> LessonMapScreen( - vm, vm.tr("Mluvnice"), "Cvičení z mluvnice – styl maturita / přijímačky z ČJL", - vm.content.mluvnice.map { m -> - val n = m.int("id") - MapNode( - "m$n", m.str("name"), false, - vm.progress.mluvDone(n), !vm.progress.mluvDone(n), - ) { vm.go(Route.MluvEx(n)) } - }, - vm.content.mluvnice.count { vm.progress.mluvDone(it.int("id")) }.toFloat(), - ) - is Route.MluvEx -> MluvExercise(vm, r.n) - Route.ReadingList -> BookListScreen(vm) - is Route.Book -> BookScreen(vm, r.id) - is Route.BookQuiz -> LitQuizScreen(vm, r.id) - is Route.BookPlot -> PlotScreen(vm, r.id) + is Route.NetEx -> NetQuizScreen(vm, r.id) + Route.HwMap -> LessonListScreen( + vm, vm.tr("Technické vybavení"), vm.tr("hw_sub"), + vm.content.hw.map { l -> + val id = l.int("id") + LessonRow(vm.tr(l.str("titleKey")), vm.progress.hwDone(id), Route.HwLesson(id)) + }, + ) + is Route.HwLesson -> { + val l = vm.content.hwLesson(r.id) + if (l != null) SlidesScreen(vm, vm.tr(l.str("titleKey")), vm.tr(l.str("subKey")), l.arr("slides")) { + vm.replace(Route.HwEx(r.id)) } } + is Route.HwEx -> HwQuizScreen(vm, r.id) + Route.CzechMap -> CzechMapScreen(vm) + Route.Mluvnice -> LessonListScreen( + vm, vm.tr("Mluvnice"), vm.tr("mluv_sub"), + vm.content.mluvnice.map { m -> + val n = m.int("id") + LessonRow(m.str("name"), vm.progress.mluvDone(n), Route.MluvEx(n)) + }, + ) + is Route.MluvEx -> MluvExercise(vm, r.n) + Route.ReadingList -> BookListScreen(vm) + is Route.Book -> BookScreen(vm, r.id) + is Route.BookQuiz -> LitQuizScreen(vm, r.id) + is Route.BookPlot -> PlotScreen(vm, r.id) } - if (vm.searchOpen) SearchOverlay(vm) - if (vm.settingsOpen) { - Box( - Modifier - .fillMaxSize() - .background(p.base), - ) { SettingsSheet(vm) } - } + if (vm.searchOpen) SearchScreen(vm) } } diff --git a/android/app/src/main/java/org/maturita/maturita/Route.kt b/android/app/src/main/java/org/maturita/maturita/Route.kt index 55203d4..daf5ead 100644 --- a/android/app/src/main/java/org/maturita/maturita/Route.kt +++ b/android/app/src/main/java/org/maturita/maturita/Route.kt @@ -4,6 +4,7 @@ sealed class Route(val page: String) { data object Welcome : Route("welcome") data object Subjects : Route("subjects") data object Stats : Route("stats") + data object Settings : Route("settings") data object Roadmap : Route("roadmap") data class UnitMap(val unitId: Int) : Route("unit${unitId + 1}") data class GermanEx(val unitId: Int, val ex: Int) : Route("u${unitId + 1}e$ex") @@ -22,12 +23,46 @@ sealed class Route(val page: String) { data class Book(val id: String) : Route(if (id == "1984") "cetba1984" else "cetbaFuks") data class BookQuiz(val id: String) : Route(if (id == "1984") "cetba1984quiz" else "cetbaFuksQuiz") data class BookPlot(val id: String) : Route(if (id == "1984") "cetba1984dej" else "cetbaFuksDej") + + /** Bottom-navigation roots; everything else is pushed on top of [Subjects]. */ + val isRoot: Boolean get() = this is Welcome || this is Subjects || this is Stats || this is Settings + + /** Screens with their own bottom action bar; the navigation bar stays hidden there. */ + val isExercise: Boolean + get() = this is GermanEx || this is Vocab || this is NetEx || this is HwEx || + this is MluvEx || this is BookQuiz || this is BookPlot || this is NetLesson || this is HwLesson + + /** Full stack from the practice root to this screen, so back always goes up one level. */ + fun stack(): List = when (this) { + Welcome, Subjects -> listOf(Subjects) + Stats -> listOf(Stats) + Settings -> listOf(Settings) + Roadmap -> listOf(Subjects, Roadmap) + is UnitMap -> listOf(Subjects, Roadmap, this) + is GermanEx -> listOf(Subjects, Roadmap, UnitMap(unitId), this) + is Vocab -> listOf(Subjects, Roadmap, UnitMap(unitId), this) + NetYears -> listOf(Subjects, NetYears) + NetMap -> listOf(Subjects, NetYears, NetMap) + is NetLesson -> listOf(Subjects, NetYears, NetMap, this) + is NetEx -> listOf(Subjects, NetYears, NetMap, NetLesson(id), this) + HwMap -> listOf(Subjects, HwMap) + is HwLesson -> listOf(Subjects, HwMap, this) + is HwEx -> listOf(Subjects, HwMap, HwLesson(id), this) + CzechMap -> listOf(Subjects, CzechMap) + Mluvnice -> listOf(Subjects, CzechMap, Mluvnice) + is MluvEx -> listOf(Subjects, CzechMap, Mluvnice, this) + ReadingList -> listOf(Subjects, CzechMap, ReadingList) + is Book -> listOf(Subjects, CzechMap, ReadingList, this) + is BookQuiz -> listOf(Subjects, CzechMap, ReadingList, Book(id), this) + is BookPlot -> listOf(Subjects, CzechMap, ReadingList, Book(id), this) + } } fun routeFromPage(page: String): Route? = when (page) { "welcome" -> Route.Welcome "subjects" -> Route.Subjects "stats" -> Route.Stats + "settings" -> Route.Settings "roadmap" -> Route.Roadmap "netyears" -> Route.NetYears "netmap" -> Route.NetMap diff --git a/android/app/src/main/java/org/maturita/maturita/data/Theme.kt b/android/app/src/main/java/org/maturita/maturita/data/Theme.kt index 6791aa7..f97dfb4 100644 --- a/android/app/src/main/java/org/maturita/maturita/data/Theme.kt +++ b/android/app/src/main/java/org/maturita/maturita/data/Theme.kt @@ -36,16 +36,48 @@ data class Palette( val finishDark: Color, val finishLight: Color, val finishStroke: Color, + /** Accent that stays readable as text and icon tint on [base]. */ + val tint: Color, + /** Text colour that reads on [tint]. */ + val onTint: Color, ) private fun hx(v: Long) = Color(0xFF000000L or v) -private fun pal(vararg c: Long) = Palette( - hx(c[0]), hx(c[1]), hx(c[2]), hx(c[3]), hx(c[4]), hx(c[5]), hx(c[6]), - hx(c[7]), hx(c[8]), hx(c[9]), hx(c[10]), hx(c[11]), hx(c[12]), hx(c[13]), - hx(c[14]), hx(c[15]), hx(c[16]), hx(c[17]), hx(c[18]), hx(c[19]), hx(c[20]), - hx(c[21]), hx(c[22]), hx(c[23]), -) +private fun luminance(v: Long): Double { + fun channel(c: Long): Double { + val s = c / 255.0 + return if (s <= 0.03928) s / 12.92 else Math.pow((s + 0.055) / 1.055, 2.4) + } + return 0.2126 * channel((v shr 16) and 0xFF) + 0.7152 * channel((v shr 8) and 0xFF) + 0.0722 * channel(v and 0xFF) +} + +private fun contrast(a: Long, b: Long): Double { + val la = luminance(a) + val lb = luminance(b) + return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05) +} + +/** Yellow on white is unreadable as link text; fall back to the next accent. */ +private fun readableTint(c: LongArray): Long { + val background = c[1] + for (candidate in longArrayOf(c[9], c[10], c[11])) { + if (contrast(candidate, background) >= 3.0) return candidate + } + return c[7] +} + +private fun pal(vararg c: Long): Palette { + val tint = readableTint(c) + return Palette( + hx(c[0]), hx(c[1]), hx(c[2]), hx(c[3]), hx(c[4]), hx(c[5]), hx(c[6]), + hx(c[7]), hx(c[8]), hx(c[9]), hx(c[10]), hx(c[11]), hx(c[12]), hx(c[13]), + hx(c[14]), hx(c[15]), hx(c[16]), hx(c[17]), hx(c[18]), hx(c[19]), hx(c[20]), + hx(c[21]), hx(c[22]), hx(c[23]), + tint = hx(tint), + onTint = if (luminance(tint) > 0.4) Color(0xFF111111) else Color.White, + ) +} fun themePalette(id: ThemeId, mode: ColorMode): Palette { val light = mode == ColorMode.Light diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Components.kt b/android/app/src/main/java/org/maturita/maturita/ui/Components.kt index 141409c..b796249 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/Components.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/Components.kt @@ -1,38 +1,77 @@ package org.maturita.maturita.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.Typography +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.material3.Typography import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontSynthesis import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.maturita.maturita.AppViewModel -import org.maturita.maturita.data.Palette +import org.maturita.maturita.Route +/** Bold is capped at Medium: synthetic bold doubled glyphs on some devices. */ fun appTextStyle( fontSize: TextUnit, fontWeight: FontWeight = FontWeight.Normal, @@ -46,186 +85,379 @@ fun appTextStyle( fontWeight = weight, fontFamily = FontFamily.SansSerif, fontSynthesis = FontSynthesis.None, - lineHeight = fontSize * 1.4f, + lineHeight = fontSize * 1.35f, letterSpacing = 0.sp, textAlign = textAlign, ) } -fun appTypography(): Typography { - val base = appTextStyle(16.sp) - return Typography( - bodyLarge = base, - bodyMedium = appTextStyle(14.sp), - bodySmall = appTextStyle(12.sp), - titleLarge = appTextStyle(22.sp, FontWeight.Medium), - titleMedium = appTextStyle(18.sp, FontWeight.Medium), - titleSmall = appTextStyle(16.sp, FontWeight.Medium), - labelLarge = appTextStyle(14.sp, FontWeight.Medium), - labelMedium = appTextStyle(12.sp, FontWeight.Medium), - headlineMedium = appTextStyle(24.sp, FontWeight.Medium), +fun appTypography(): Typography = Typography( + displaySmall = appTextStyle(34.sp, FontWeight.Medium), + headlineLarge = appTextStyle(30.sp, FontWeight.Medium), + headlineMedium = appTextStyle(26.sp, FontWeight.Medium), + headlineSmall = appTextStyle(22.sp, FontWeight.Medium), + titleLarge = appTextStyle(20.sp, FontWeight.Medium), + titleMedium = appTextStyle(16.sp, FontWeight.Medium), + titleSmall = appTextStyle(14.sp, FontWeight.Medium), + bodyLarge = appTextStyle(16.sp), + bodyMedium = appTextStyle(14.sp), + bodySmall = appTextStyle(12.sp), + labelLarge = appTextStyle(14.sp, FontWeight.Medium), + labelMedium = appTextStyle(12.sp, FontWeight.Medium), + labelSmall = appTextStyle(11.sp, FontWeight.Medium), +) + +// --------------------------------------------------------------------------- +// Chrome + +@Composable +fun AppNavBar(vm: AppViewModel) { + val root = vm.root + NavigationBar { + NavigationBarItem( + selected = root == Route.Subjects, + onClick = { vm.go(Route.Subjects) }, + icon = { SubjectsIcon(navIconColor(root == Route.Subjects)) }, + label = { Text(vm.tr("subjects_title"), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) + NavigationBarItem( + selected = root == Route.Stats, + onClick = { vm.go(Route.Stats) }, + icon = { StatsIcon(navIconColor(root == Route.Stats), 22.dp) }, + label = { Text(vm.tr("stats"), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) + NavigationBarItem( + selected = root == Route.Settings, + onClick = { vm.go(Route.Settings) }, + icon = { SettingsIcon(navIconColor(root == Route.Settings), 22.dp) }, + label = { Text(vm.tr("settings"), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) + } +} + +@Composable +private fun navIconColor(selected: Boolean): Color = + if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant + +/** Tab root: large collapsing title, search action, bottom navigation. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RootScaffold( + vm: AppViewModel, + title: String, + showSearch: Boolean = true, + content: LazyListScope.() -> Unit, +) { + val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier = Modifier.nestedScroll(scroll.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + actions = { + if (showSearch) { + IconButton(onClick = { vm.searchOpen = true }) { + Icon(Icons.Filled.Search, contentDescription = vm.tr("search_placeholder")) + } + } + }, + scrollBehavior = scroll, + ) + }, + bottomBar = { AppNavBar(vm) }, + ) { inner -> + LazyColumn( + contentPadding = PaddingValues( + top = inner.calculateTopPadding(), + bottom = inner.calculateBottomPadding() + 16.dp, + ), + ) { content() } + } +} + +/** Pushed screen: back arrow, title with optional subtitle, optional bottom action bar. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailScaffold( + vm: AppViewModel, + title: String, + subtitle: String? = null, + bottomBar: (@Composable () -> Unit)? = null, + content: @Composable (PaddingValues) -> Unit, +) { + val scroll = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier = Modifier.nestedScroll(scroll.nestedScrollConnection), + topBar = { + TopAppBar( + title = { + Column { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleLarge) + if (!subtitle.isNullOrBlank()) { + Text( + subtitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { vm.back() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = vm.tr("back")) + } + }, + scrollBehavior = scroll, + ) + }, + bottomBar = { bottomBar?.invoke() }, + content = content, ) } -private val Pill = RoundedCornerShape(999.dp) -private val Card = RoundedCornerShape(20.dp) - +/** Feedback line plus the primary action, anchored at the bottom of an exercise. */ @Composable -fun PrimaryButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) { - Box( - modifier - .clip(Pill) - .background(palette.accent) - .clickable(onClick = onClick) - .padding(horizontal = 22.dp, vertical = 12.dp), - contentAlignment = Alignment.Center, - ) { - Text(label, style = appTextStyle(15.sp, FontWeight.Medium, color = palette.onAccent)) - } -} - -@Composable -fun PillButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) { - Box( - modifier - .clip(Pill) - .background(palette.text.copy(alpha = 0.06f)) - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text(label, style = appTextStyle(13.sp, FontWeight.Medium, color = palette.text)) - } -} - -@Composable -fun IconHit(onClick: () -> Unit, content: @Composable () -> Unit) { - Box( - Modifier - .size(40.dp) - .clip(CircleShape) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { content() } -} - -@Composable -fun PageTop(vm: AppViewModel, back: () -> Unit, title: String, subtitle: String? = null) { - val p = vm.palette - Column(Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 14.dp)) { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Box(Modifier.align(Alignment.CenterStart)) { - IconHit(back) { BackIcon(p.text) } +fun ActionBar( + label: String, + feedback: String = "", + kind: String = "", + enabled: Boolean = true, + leading: (@Composable () -> Unit)? = null, + onClick: () -> Unit, +) { + Surface(color = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 2.dp) { + Column( + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + if (feedback.isNotEmpty()) { + FeedbackLine(feedback, kind) + Spacer(Modifier.height(10.dp)) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + leading?.invoke() + Button(onClick = onClick, enabled = enabled, modifier = Modifier.weight(1f).height(50.dp)) { + Text(label, style = MaterialTheme.typography.titleMedium) + } } - Text( - title, - modifier = Modifier.padding(horizontal = 48.dp), - style = appTextStyle(22.sp, FontWeight.Medium, TextAlign.Center, p.text), - ) - } - if (!subtitle.isNullOrBlank()) { - Text( - subtitle, - modifier = Modifier.fillMaxWidth().padding(top = 6.dp), - style = appTextStyle(14.sp, textAlign = TextAlign.Center, color = p.subtext), - ) } } } +// --------------------------------------------------------------------------- +// Lists + @Composable -fun CardBox(palette: Palette, modifier: Modifier = Modifier, content: @Composable () -> Unit) { - Box( - modifier - .clip(Card) - .background(palette.mantle) - .border(1.dp, palette.text.copy(alpha = 0.07f), Card) - .padding(20.dp), - ) { content() } +fun SectionHeader(text: String) { + Text( + text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 6.dp), + ) +} + +/** One navigable row: done/pending/locked state on the left, chevron on the right. */ +@Composable +fun NavRow( + title: String, + subtitle: String? = null, + done: Boolean = false, + locked: Boolean = false, + leading: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)?, +) { + val enabled = !locked && onClick != null + val textColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant + val rowModifier = if (enabled) Modifier.clickable(onClick = onClick!!) else Modifier + ListItem( + headlineContent = { Text(title, color = textColor) }, + supportingContent = subtitle?.takeIf { it.isNotBlank() }?.let { + { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) } + }, + leadingContent = { + when { + leading != null -> leading() + locked -> Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.outline) + done -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) + else -> Icon(Icons.Filled.RadioButtonUnchecked, null, tint = MaterialTheme.colorScheme.outline) + } + }, + trailingContent = if (enabled) { + { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = MaterialTheme.colorScheme.outline) } + } else null, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = rowModifier, + ) +} + +// --------------------------------------------------------------------------- +// Exercise widgets + +@Composable +fun PrimaryButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) { + Button(onClick = onClick, modifier = modifier) { Text(label) } } @Composable -fun FeedbackLine(text: String, kind: String, palette: Palette) { +fun PillButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) { + FilledTonalButton(onClick = onClick, modifier = modifier) { Text(label) } +} + +@Composable +fun FeedbackLine(text: String, kind: String) { if (text.isEmpty()) return val color = when (kind) { - "ok" -> palette.success - "warn" -> palette.warning - else -> palette.error + "ok" -> MaterialTheme.colorScheme.primary + "warn" -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.error + } + val icon = when (kind) { + "ok" -> Icons.Filled.CheckCircle + "warn" -> Icons.Filled.Warning + else -> Icons.Filled.Cancel + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, tint = color, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(text, color = color, style = MaterialTheme.typography.titleSmall) } - Text(text, color = color, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp)) } @Composable -fun Meaning(text: String, palette: Palette, visible: Boolean) { +fun Meaning(text: String, visible: Boolean) { if (!visible || text.isEmpty()) return - Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp)) + Text( + text, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp), + ) +} + +@Composable +fun Hint(text: String) { + Text( + text, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) +} + +@Composable +fun Prompt(text: String) { + Text(text, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp, bottom = 4.dp)) } @Composable fun WordField( value: String, onValue: (String) -> Unit, - palette: Palette, placeholder: String, mark: Boolean? = null, modifier: Modifier = Modifier, + numeric: Boolean = false, ) { - val border = when (mark) { - true -> palette.success - false -> palette.error - null -> palette.text.copy(alpha = 0.12f) - } - Box( - modifier - .clip(RoundedCornerShape(14.dp)) - .background(palette.surface0) - .border(1.dp, border, RoundedCornerShape(14.dp)) - .padding(horizontal = 14.dp, vertical = 12.dp) - .fillMaxWidth(), - ) { - if (value.isEmpty()) Text(placeholder, color = palette.overlay, fontSize = 15.sp) - BasicTextField( - value = value, - onValueChange = onValue, - textStyle = TextStyle(color = palette.text, fontSize = 15.sp), - cursorBrush = SolidColor(palette.accent), - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@Composable -fun Segmented(palette: Palette, content: @Composable RowScope.() -> Unit) { - Row( - Modifier - .fillMaxWidth() - .clip(Pill) - .background(palette.surface1) - .padding(3.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - content = content, + OutlinedTextField( + value = value, + onValueChange = onValue, + placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder) }) else null, + singleLine = true, + isError = mark == false, + trailingIcon = when (mark) { + true -> ({ Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) }) + false -> ({ Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error) }) + null -> null + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = if (numeric) KeyboardType.Decimal else KeyboardType.Text, + ), + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), ) } +/** Tappable word in a bank or a built sentence. */ @Composable -fun SegChip(label: String, selected: Boolean, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) { - Box( - modifier - .clip(Pill) - .background(if (selected) palette.mantle else Color.Transparent) - .clickable(onClick = onClick) - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center, +fun Chip(text: String, onClick: () -> Unit) { + SuggestionChip(onClick = onClick, label = { Text(text) }) +} + +@Composable +fun SegChip(label: String, selected: Boolean, onClick: () -> Unit) { + FilterChip(selected = selected, onClick = onClick, label = { Text(label) }) +} + +/** Multiple-choice answer with radio state and, after checking, a correct/wrong mark. */ +@Composable +fun OptionRow(text: String, selected: Boolean, mark: Boolean?, enabled: Boolean = true, onClick: () -> Unit) { + val container = when (mark) { + true -> MaterialTheme.colorScheme.primaryContainer + false -> MaterialTheme.colorScheme.errorContainer + null -> if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh + } + Surface( + onClick = onClick, + enabled = enabled, + color = container, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), ) { - Text(label, color = palette.text, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + Row(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + when (mark) { + true -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) + false -> Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error) + null -> RadioButton(selected = selected, onClick = null) + } + Spacer(Modifier.width(12.dp)) + Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + } } } +/** Drop zone for word ordering; visible even without words. */ +@OptIn(ExperimentalLayoutApi::class) @Composable -fun Hint(text: String, palette: Palette) { - Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp)) +fun WordSlot(empty: Boolean, content: @Composable () -> Unit) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) { + FlowRow( + Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (empty) Text("…", color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding(8.dp)) + content() + } + } } +/** Bottom sheet with the word bank for combo/dialogue blanks. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun Prompt(text: String, palette: Palette) { - Text(text, color = palette.text, fontSize = 16.sp, fontWeight = FontWeight.Medium) +fun WordPicker(title: String, pool: List, onPick: (String?) -> Unit) { + ModalBottomSheet(onDismissRequest = { onPick(null) }) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + LazyColumn(contentPadding = PaddingValues(bottom = 24.dp)) { + items(pool.size) { i -> + ListItem( + headlineContent = { Text(pool[i]) }, + modifier = Modifier.clickable { onPick(pool[i]) }, + ) + } + } + } } diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt b/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt index fb62ca4..8b00cf7 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/Exercises.kt @@ -1,14 +1,12 @@ package org.maturita.maturita.ui import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -16,10 +14,26 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.layout.heightIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -33,21 +47,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import org.maturita.maturita.AppViewModel import org.maturita.maturita.data.J -import org.maturita.maturita.data.Palette import org.maturita.maturita.data.answerAccepts import org.maturita.maturita.data.answerIncomplete import org.maturita.maturita.data.hasUmlaut import org.maturita.maturita.data.netTxtEq import org.maturita.maturita.data.normalizeAnswer +/** Scrollable exercise body with the check button anchored at the bottom. */ @Composable fun ExerciseScaffold( vm: AppViewModel, @@ -59,13 +74,16 @@ fun ExerciseScaffold( kind: String, content: @Composable () -> Unit, ) { - val p = vm.palette - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, title, sub) - Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { content() } - FeedbackLine(feedback, kind, p) - Box(Modifier.padding(vertical = 12.dp)) { - PrimaryButton(action, p, onClick = onAction) + DetailScaffold(vm, title, sub, bottomBar = { ActionBar(action, feedback, kind, onClick = onAction) }) { inner -> + Column( + Modifier + .fillMaxSize() + .padding(inner) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + content() + Spacer(Modifier.height(16.dp)) } } } @@ -73,7 +91,7 @@ fun ExerciseScaffold( @Composable fun GermanExercise(vm: AppViewModel, unitId: Int, ex: Int) { val spec = vm.content.germanUnit(unitId)?.exercise(ex) ?: return - DispatchExercise(vm, spec, vm.tr(spec.str("title").ifEmpty { spec.str("title") }).ifEmpty { spec.str("title") }, onDone = { vm.markGerman(unitId, ex) }) + DispatchExercise(vm, spec, spec.str("title"), onDone = { vm.markGerman(unitId, ex) }) } @Composable @@ -100,7 +118,7 @@ fun DispatchExercise(vm: AppViewModel, spec: J, titleFallback: String, onDone: ( "letters", "letters_gap" -> LettersEx(vm, title, sub, spec, onDone) "table" -> TableEx(vm, title, sub, spec, onDone) "reveal" -> RevealEx(vm, title, sub, spec, onDone) - else -> Text("?", color = vm.palette.text) + else -> DetailScaffold(vm, title, sub) { inner -> Text("?", Modifier.padding(inner).padding(16.dp)) } } } @@ -112,57 +130,34 @@ private fun ChoiceEx(vm: AppViewModel, title: String, sub: String?, spec: J, onD val picks = remember { mutableStateListOf(*Array(qs.size) { -1 }) } var fb by remember { mutableStateOf("" to "") } var revealed by remember { mutableStateOf(false) } - val p = vm.palette ExerciseScaffold(vm, title, sub, vm.tr("check"), { - var ok = 0 - qs.forEachIndexed { i, q -> if (picks[i] == q.int("correct")) ok++ } + val ok = qs.indices.count { picks[it] == qs[it].int("correct") } if (ok == qs.size) { fb = vm.tr("feedback_ok") to "ok"; onDone() } else fb = vm.tr("feedback_retry_short") to "err" revealed = true }, fb.first, fb.second) { qs.forEachIndexed { i, q -> - Text("${i + 1}. ${q.str("prompt")}", color = p.text, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 6.dp)) + Text("${i + 1}. ${q.str("prompt")}", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp)) q.strs("options").forEachIndexed { o, opt -> val sel = picks[i] == o val mark = if (revealed && sel) picks[i] == q.int("correct") else null - OptionChip(opt, sel, mark, p) { picks[i] = o } + OptionRow(opt, sel, mark) { picks[i] = o; revealed = false } } if (revealed) { - meanings.getOrNull(i)?.let { Meaning(vm.tr(it), p, true) } - expls.getOrNull(i)?.let { Meaning(it, p, true) } + meanings.getOrNull(i)?.let { Meaning(vm.tr(it), true) } + expls.getOrNull(i)?.let { Meaning(it, true) } } - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(12.dp)) } } } -@Composable -private fun OptionChip(text: String, selected: Boolean, mark: Boolean?, p: Palette, onClick: () -> Unit) { - val border = when (mark) { - true -> p.success - false -> p.error - null -> if (selected) p.accent else p.text.copy(0.1f) - } - Box( - Modifier - .fillMaxWidth() - .padding(bottom = 6.dp) - .clip(RoundedCornerShape(14.dp)) - .background(if (selected) p.surface1 else p.mantle) - .border(1.dp, border, RoundedCornerShape(14.dp)) - .clickable(onClick = onClick) - .padding(12.dp), - ) { Text(text, color = p.text) } -} - @OptIn(ExperimentalLayoutApi::class) @Composable private fun AssemblyEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { val items = spec.arr("items") val meanings = spec.strs("meanings") - val placed = remember { - items.map { item -> mutableStateListOf() } - } + val placed = remember { items.map { mutableStateListOf() } } val pools = remember { items.map { item -> val n = item.strs("words").size @@ -171,14 +166,7 @@ private fun AssemblyEx(vm: AppViewModel, title: String, sub: String?, spec: J, o } var fb by remember { mutableStateOf("" to "") } var revealed by remember { mutableStateOf(false) } - val p = vm.palette ExerciseScaffold(vm, title, sub, vm.tr("check"), { - val all = items.indices.all { i -> - val words = items[i].strs("words") - placed[i].size == words.size && placed[i].mapIndexed { idx, w -> words[w] == words[idx] }.all { it } || - placed[i].map { words[it] } == words - } - // compare placed order to original word order val ok = items.indices.all { i -> val words = items[i].strs("words") placed[i].size == words.size && placed[i].map { words[it] } == words @@ -188,44 +176,32 @@ private fun AssemblyEx(vm: AppViewModel, title: String, sub: String?, spec: J, o revealed = true }, fb.first, fb.second) { items.forEachIndexed { i, item -> - item.strOrNull("prompt")?.let { Hint(it, p) } - FlowRow(Modifier.fillMaxWidth().padding(bottom = 6.dp).clip(RoundedCornerShape(14.dp)).background(p.surface0).padding(8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (placed[i].isEmpty()) Text("…", color = p.overlay) - placed[i].forEach { idx -> - val word = item.strs("words").getOrNull(idx) ?: return@forEach - Chip(word, p) { + val words = item.strs("words") + item.strOrNull("prompt")?.let { Hint(it) } + WordSlot(placed[i].isEmpty()) { + placed[i].toList().forEach { idx -> + val word = words.getOrNull(idx) ?: return@forEach + Chip(word) { placed[i].remove(idx) pools[i].add(idx) } } } - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { pools[i].toList().forEach { idx -> - val word = item.strs("words").getOrNull(idx) ?: return@forEach - Chip(word, p) { + val word = words.getOrNull(idx) ?: return@forEach + Chip(word) { pools[i].remove(idx) placed[i].add(idx) } } } - if (revealed) meanings.getOrNull(i)?.let { Meaning(vm.tr(it), p, true) } - Spacer(Modifier.height(12.dp)) + if (revealed) meanings.getOrNull(i)?.let { Meaning(vm.tr(it), true) } + Spacer(Modifier.height(16.dp)) } } } -@Composable -private fun Chip(text: String, p: Palette, onClick: () -> Unit) { - Box( - Modifier - .clip(RoundedCornerShape(999.dp)) - .background(p.mantle) - .border(1.dp, p.text.copy(0.1f), RoundedCornerShape(999.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { Text(text, color = p.text, fontWeight = FontWeight.Medium) } -} - @Composable private fun TypedEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { val rows = spec.arr("rows") @@ -238,10 +214,7 @@ private fun TypedEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo } var fb by remember { mutableStateOf("" to "") } var revealed by remember { mutableStateOf(false) } - val p = vm.palette - val umlaut = items.any { q -> - q.answerList().any { hasUmlaut(it) } || hasUmlaut(q.str("prompt")) - } + val umlaut = items.any { q -> q.answerList().any { hasUmlaut(it) } || hasUmlaut(q.str("prompt")) } ExerciseScaffold(vm, title, sub, vm.tr("check"), { var ok = 0 var total = 0 @@ -265,29 +238,28 @@ private fun TypedEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo } revealed = true }, fb.first, fb.second) { - spec.strOrNull("note")?.let { Hint(it, p) } - spec.strOrNull("sample")?.let { Hint(it, p) } - spec.strOrNull("bank")?.let { Hint("${vm.tr("wordbank")} $it", p) } - if (umlaut) Hint(vm.tr("hint_umlauts"), p) + spec.strOrNull("note")?.let { Hint(it) } + spec.strOrNull("sample")?.let { Hint(it) } + spec.strOrNull("bank")?.let { Hint("${vm.tr("wordbank")} $it") } + if (umlaut) Hint(vm.tr("hint_umlauts")) items.forEachIndexed { i, q -> - Prompt(q.str("prompt"), p) + Prompt(q.str("prompt")) val answers = q.answerList() val hints = q.strs("hints") fields[i].forEachIndexed { j, value -> val ans = answers.getOrNull(j) ?: "" WordField( value, - { fields[i][j] = it }, - p, + { fields[i][j] = it; revealed = false }, hints.getOrNull(j) ?: vm.tr("your_answer"), if (revealed) answerAccepts(normalizeAnswer(value), ans) else null, ) } if (revealed) { - q.strOrNull("meaning")?.let { Meaning(vm.tr(it), p, true) } - q.strOrNull("mean")?.let { Meaning(vm.tr(it), p, true) } - q.strOrNull("german")?.let { Meaning(it, p, true) } - q.strOrNull("shown")?.let { Meaning(it, p, true) } + q.strOrNull("meaning")?.let { Meaning(vm.tr(it), true) } + q.strOrNull("mean")?.let { Meaning(vm.tr(it), true) } + q.strOrNull("german")?.let { Meaning(it, true) } + q.strOrNull("shown")?.let { Meaning(it, true) } } Spacer(Modifier.height(10.dp)) } @@ -299,28 +271,24 @@ private fun FreeEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDon val qs = if (spec.arr("questions").isNotEmpty()) spec.arr("questions") else spec.arr("rows") val shown = remember { mutableStateListOf(*Array(qs.size) { false }) } var fb by remember { mutableStateOf("" to "") } - val p = vm.palette val values = remember { mutableStateListOf(*Array(qs.size) { "" }) } ExerciseScaffold(vm, title, sub, vm.tr("finish"), { fb = vm.tr("feedback_ok") to "ok" qs.indices.forEach { shown[it] = true } onDone() }, fb.first, fb.second) { - spec.strOrNull("tip")?.let { Hint(vm.tr(it), p) } + spec.strOrNull("tip")?.let { Hint(vm.tr(it)) } qs.forEachIndexed { i, q -> - Prompt(q.str("question").ifEmpty { q.str("stem") }, p) - WordField(values[i], { values[i] = it }, p, vm.tr("your_answer")) - PillButton(vm.tr("show_sample"), p, Modifier.padding(vertical = 6.dp)) { shown[i] = !shown[i] } + Prompt(q.str("question").ifEmpty { q.str("stem") }) + WordField(values[i], { values[i] = it }, vm.tr("your_answer")) + PillButton(vm.tr("show_sample"), Modifier.padding(vertical = 4.dp)) { shown[i] = !shown[i] } if (shown[i]) { val sample = q.str("sample") val qcs = q.str("qCs") val acs = q.str("aCs") - Meaning( - if (qcs.isNotEmpty()) vm.fmt("sample_fmt", vm.tr(qcs), sample, vm.tr(acs)) else sample, - p, true, - ) + Meaning(if (qcs.isNotEmpty()) vm.fmt("sample_fmt", vm.tr(qcs), sample, vm.tr(acs)) else sample, true) } - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(12.dp)) } } } @@ -335,47 +303,34 @@ private fun AssignEx(vm: AppViewModel, title: String, sub: String?, spec: J, onD val loc = remember { mutableStateListOf(*Array(items.size) { -1 }) } var fb by remember { mutableStateOf("" to "") } var revealed by remember { mutableStateOf(false) } - val p = vm.palette + fun label(it: J) = listOfNotNull(it.strOrNull("emoji"), it.str("label")).joinToString(" ") ExerciseScaffold(vm, title, sub, vm.tr("check"), { val ok = items.indices.all { loc[it] == items[it].int("group") } if (ok) { fb = vm.tr("feedback_ok") to "ok"; onDone() } else fb = vm.tr("feedback_retry") to "err" revealed = true }, fb.first, fb.second) { - Hint(vm.tr("assign_hint"), p) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - groups.forEachIndexed { i, g -> - SegChip(g, active == i, p) { active = i } - } + Hint(vm.tr("assign_hint")) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + groups.forEachIndexed { i, g -> SegChip(g, active == i) { active = i } } } - Spacer(Modifier.height(10.dp)) - Text(vm.tr("wordbank"), color = p.subtext, fontSize = 12.sp) - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - items.forEachIndexed { i, it -> - if (loc[i] == -1) { - val label = listOfNotNull(it.strOrNull("emoji"), it.str("label")).joinToString(" ") - Chip(label, p) { loc[i] = active } - } - } + Spacer(Modifier.height(12.dp)) + Text(vm.tr("wordbank"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + items.forEachIndexed { i, it -> if (loc[i] == -1) Chip(label(it)) { loc[i] = active } } } groups.forEachIndexed { g, name -> - Spacer(Modifier.height(10.dp)) - Text(name, color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - items.forEachIndexed { i, it -> - if (loc[i] == g) { - val label = listOfNotNull(it.strOrNull("emoji"), it.str("label")).joinToString(" ") - Chip(label, p) { loc[i] = -1 } - } - } + Spacer(Modifier.height(12.dp)) + Text(name, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + WordSlot(items.indices.none { loc[it] == g }) { + items.forEachIndexed { i, it -> if (loc[i] == g) Chip(label(it)) { loc[i] = -1 } } } } - if (revealed) items.forEachIndexed { i, _ -> - meanings.getOrNull(i)?.let { Meaning(vm.tr(it), p, true) } - } + if (revealed) items.indices.forEach { i -> meanings.getOrNull(i)?.let { Meaning(vm.tr(it), true) } } } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun HangmanEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { val words = spec.strs("words") @@ -386,77 +341,79 @@ private fun HangmanEx(vm: AppViewModel, title: String, sub: String?, spec: J, on val guessed = remember { mutableStateListOf(*Array(letters.size) { false }) } var fb by remember { mutableStateOf("" to "") } var finished by remember { mutableStateOf(false) } - val p = vm.palette val cur = words.getOrNull(word)?.uppercase() ?: "" - fun solved() = cur.all { ch -> - val idx = letters.indexOfFirst { it.equals(ch.toString(), true) } - idx >= 0 && guessed[idx] - } - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, title, sub) - Text(vm.fmt("hm_progress", word + 1, words.size), color = p.subtext) - tips.getOrNull(word)?.let { Hint("${vm.tr("hm_hint")}: ${vm.tr(it)}", p) } - Canvas(Modifier.fillMaxWidth().height(160.dp)) { - val x = size.width * 0.3f - drawLine(p.text, Offset(40f, size.height - 10), Offset(size.width * 0.55f, size.height - 10), 6f, StrokeCap.Round) - drawLine(p.text, Offset(70f, size.height - 10), Offset(70f, 16f), 6f, StrokeCap.Round) - drawLine(p.text, Offset(70f, 16f), Offset(x + 40, 16f), 6f, StrokeCap.Round) - if (misses > 0) drawLine(p.text, Offset(x + 40, 16f), Offset(x + 40, 36f), 4f) - if (misses > 1) drawCircle(p.text, 16f, Offset(x + 40, 52f), style = androidx.compose.ui.graphics.drawscope.Stroke(4f)) - if (misses > 2) drawLine(p.text, Offset(x + 40, 68f), Offset(x + 40, 110f), 4f) - if (misses > 3) drawLine(p.text, Offset(x + 40, 80f), Offset(x + 18, 100f), 4f) - if (misses > 4) drawLine(p.text, Offset(x + 40, 80f), Offset(x + 62, 100f), 4f) - if (misses > 5) drawLine(p.text, Offset(x + 40, 110f), Offset(x + 18, 140f), 4f) - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - cur.forEach { ch -> - val idx = letters.indexOfFirst { it.equals(ch.toString(), true) } - val show = idx >= 0 && guessed[idx] - Text(if (show) ch.toString() else "_", color = p.text, fontSize = 26.sp, fontWeight = FontWeight.Bold) - } - } - Spacer(Modifier.height(8.dp)) - letters.chunked(10).forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - row.forEach { letter -> - val i = letters.indexOf(letter) - Box( - Modifier - .clip(RoundedCornerShape(8.dp)) - .background(if (guessed[i]) p.surface1 else p.mantle) - .clickable(enabled = !guessed[i] && !finished) { - guessed[i] = true - if (!cur.contains(letter, true)) { - misses++ - if (misses >= 6) { - fb = vm.fmt("hm_fail", cur) to "err" - } - } else if (solved()) { - if (word + 1 >= words.size) { - finished = true - fb = vm.tr("hm_done") to "ok" - onDone() - } else { - fb = vm.tr("hm_wrong") to "ok" - word++ - misses = 0 - guessed.indices.forEach { guessed[it] = false } - } - } - } - .padding(horizontal = 10.dp, vertical = 8.dp), - ) { Text(letter, color = p.text, fontWeight = FontWeight.Bold) } + fun idxOf(ch: Char) = letters.indexOfFirst { it.equals(ch.toString(), true) } + fun solved() = cur.all { ch -> idxOf(ch).let { it >= 0 && guessed[it] } } + val stroke = MaterialTheme.colorScheme.onSurface + DetailScaffold( + vm, title, sub, + bottomBar = if (misses >= 6 && !finished) { + { + ActionBar(vm.tr("hm_retry"), fb.first, fb.second) { + misses = 0 + guessed.indices.forEach { guessed[it] = false } + fb = "" to "" } } - Spacer(Modifier.height(4.dp)) - } - FeedbackLine(fb.first, fb.second, p) - if (misses >= 6 && !finished) { - PillButton(vm.tr("hm_retry"), p) { - misses = 0 - guessed.indices.forEach { guessed[it] = false } - fb = "" to "" + } else null, + ) { inner -> + Column( + Modifier.fillMaxSize().padding(inner).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text(vm.fmt("hm_progress", word + 1, words.size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + tips.getOrNull(word)?.let { Hint("${vm.tr("hm_hint")}: ${vm.tr(it)}") } + Canvas(Modifier.fillMaxWidth().height(160.dp)) { + val x = size.width * 0.3f + drawLine(stroke, Offset(40f, size.height - 10), Offset(size.width * 0.55f, size.height - 10), 6f, StrokeCap.Round) + drawLine(stroke, Offset(70f, size.height - 10), Offset(70f, 16f), 6f, StrokeCap.Round) + drawLine(stroke, Offset(70f, 16f), Offset(x + 40, 16f), 6f, StrokeCap.Round) + if (misses > 0) drawLine(stroke, Offset(x + 40, 16f), Offset(x + 40, 36f), 4f) + if (misses > 1) drawCircle(stroke, 16f, Offset(x + 40, 52f), style = Stroke(4f)) + if (misses > 2) drawLine(stroke, Offset(x + 40, 68f), Offset(x + 40, 110f), 4f) + if (misses > 3) drawLine(stroke, Offset(x + 40, 80f), Offset(x + 18, 100f), 4f) + if (misses > 4) drawLine(stroke, Offset(x + 40, 80f), Offset(x + 62, 100f), 4f) + if (misses > 5) drawLine(stroke, Offset(x + 40, 110f), Offset(x + 18, 140f), 4f) } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + cur.forEach { ch -> + val show = idxOf(ch).let { it >= 0 && guessed[it] } + Text( + if (show) ch.toString() else "_", + style = MaterialTheme.typography.headlineMedium.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.padding(horizontal = 3.dp), + ) + } + } + Spacer(Modifier.height(12.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + letters.forEachIndexed { i, letter -> + OutlinedButton( + enabled = !guessed[i] && !finished, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.widthIn(min = 44.dp).height(40.dp), + onClick = { + guessed[i] = true + if (!cur.contains(letter, true)) { + misses++ + if (misses >= 6) fb = vm.fmt("hm_fail", cur) to "err" + } else if (solved()) { + if (word + 1 >= words.size) { + finished = true + fb = vm.tr("hm_done") to "ok" + onDone() + } else { + fb = vm.tr("hm_wrong") to "ok" + word++ + misses = 0 + guessed.indices.forEach { guessed[it] = false } + } + } + }, + ) { Text(letter) } + } + } + Spacer(Modifier.height(12.dp)) + FeedbackLine(fb.first, fb.second) } } } @@ -466,59 +423,88 @@ private fun VocabEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo val cards = remember { spec.arr("sections").flatMap { sec -> sec.arr("rows").map { Triple(sec.str("header"), it.str("prompt"), it.str("answers")) } - }.toMutableList() + }.toMutableStateList() } var index by remember { mutableIntStateOf(0) } var done by remember { mutableIntStateOf(0) } var value by remember { mutableStateOf("") } var fb by remember { mutableStateOf("" to "") } val total = remember { cards.size } - val p = vm.palette val cur = cards.getOrNull(index) - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, title, sub) - Text(vm.fmt("trans_progress", done, total), color = p.subtext) - if (cur == null) { - Text(vm.tr("trans_done"), color = p.success, fontWeight = FontWeight.Bold) - } else { - Hint(cur.first, p) - Prompt(cur.second, p) - if (hasUmlaut(cur.third)) Hint(vm.tr("hint_umlauts"), p) - WordField(value, { value = it }, p, vm.tr("your_answer")) - Spacer(Modifier.height(10.dp)) - PrimaryButton(vm.tr("check"), p) { - val norm = normalizeAnswer(value) - when { - answerAccepts(norm, cur.third) -> { - done++ - cards.removeAt(index) - if (cards.isEmpty()) { fb = vm.tr("trans_done") to "ok"; onDone() } - else { if (index >= cards.size) index = 0; value = ""; fb = "" to "" } - } - answerIncomplete(norm, cur.third) -> fb = vm.tr("trans_incomplete") to "warn" - else -> { - fb = vm.fmt("trans_wrong", cur.third.split('|').first()) to "err" - val item = cards.removeAt(index) - cards.add(item) - if (index >= cards.size) index = 0 - value = "" + fun check() { + val c = cur ?: return + val norm = normalizeAnswer(value) + when { + answerAccepts(norm, c.third) -> { + done++ + cards.removeAt(index) + if (cards.isEmpty()) { fb = vm.tr("trans_done") to "ok"; onDone() } + else { if (index >= cards.size) index = 0; value = ""; fb = "" to "" } + } + answerIncomplete(norm, c.third) -> fb = vm.tr("trans_incomplete") to "warn" + else -> { + fb = vm.fmt("trans_wrong", c.third.split('|').first()) to "err" + val item = cards.removeAt(index) + cards.add(item) + if (index >= cards.size) index = 0 + value = "" + } + } + } + DetailScaffold( + vm, title, sub, + bottomBar = if (cur != null) ({ ActionBar(vm.tr("check"), fb.first, fb.second) { check() } }) else null, + ) { inner -> + Column(Modifier.fillMaxSize().padding(inner).verticalScroll(rememberScrollState()).padding(16.dp)) { + LinearProgressIndicator( + progress = { if (total == 0) 0f else done / total.toFloat() }, + modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape), + ) + Spacer(Modifier.height(6.dp)) + Text(vm.fmt("trans_progress", done, total), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(12.dp)) + if (cur == null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.EmojiEvents, null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text(vm.tr("trans_done"), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + } else { + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) { + Column(Modifier.padding(20.dp)) { + Text(cur.first, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.75f)) + Spacer(Modifier.height(4.dp)) + Text(cur.second, style = MaterialTheme.typography.headlineSmall) } } + Spacer(Modifier.height(12.dp)) + if (hasUmlaut(cur.third)) Hint(vm.tr("hint_umlauts")) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + placeholder = { Text(vm.tr("your_answer")) }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { check() }), + modifier = Modifier.fillMaxWidth(), + ) } - FeedbackLine(fb.first, fb.second, p) } } } +private fun List>.toMutableStateList() = mutableStateListOf(*this.toTypedArray()) + @Composable private fun DialogEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { val pool = spec.strs("pool") val meanings = spec.strs("meanings") val rows = spec.arr("dialogues").flatMap { it.arr("rows") } - ComboRows(vm, title, sub, pool, rows.size, onDone) { picks, expected, revealed -> + ComboRows(vm, title, sub, rows.size, onDone) { picks, expected, revealed -> var i = 0 - spec.arr("dialogues").forEach { d -> - Text(d.str("name"), color = vm.palette.subtext, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(vertical = 6.dp)) + spec.arr("dialogues").forEachIndexed { di, d -> + if (di > 0) HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Text(d.str("name"), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(vertical = 6.dp)) d.arr("rows").forEach { row -> val idx = i++ ComboLine( @@ -543,14 +529,14 @@ private fun ComboEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo if (type == "verb_sections") { val sections = spec.arr("sections") val allRows = sections.flatMap { it.arr("rows") } - ComboRows(vm, title, sub, spec.strs("pool"), allRows.size, onDone, spec.bool("transUpfront")) { picks, expected, revealed -> + ComboRows(vm, title, sub, allRows.size, onDone, spec.bool("transUpfront")) { picks, expected, revealed -> var i = 0 sections.forEach { sec -> - Text(sec.str("title"), color = vm.palette.subtext, fontWeight = FontWeight.SemiBold) + Text(sec.str("title"), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(vertical = 6.dp)) sec.arr("rows").forEachIndexed { ri, row -> val idx = i++ ComboLine( - vm, spec.strs("pool"), picks, expected, idx, row.str("answer"), + vm, pool, picks, expected, idx, row.str("answer"), prefix = row.str("before"), suffix = row.str("after"), meaning = sec.strs("meanings").getOrNull(ri), revealed = revealed || spec.bool("transUpfront"), @@ -561,18 +547,19 @@ private fun ComboEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo return } if (type == "ordne") { - ComboRows(vm, title, sub, emptyList(), rows.size * 2, onDone) { picks, expected, revealed -> + ComboRows(vm, title, sub, rows.size * 2, onDone) { picks, expected, revealed -> rows.forEachIndexed { i, r -> ComboLine(vm, spec.strs("fwPool"), picks, expected, i * 2, r.str("fw"), prefix = "${i + 1}. ", suffix = r.str("mid"), revealed = revealed) ComboLine(vm, spec.strs("ansPool"), picks, expected, i * 2 + 1, r.str("ans"), prefix = "→ ", meaning = meanings.getOrNull(i), revealed = revealed) + Spacer(Modifier.height(6.dp)) } } return } if (type == "fill") { val blanks = rows.sumOf { it.answerList().size } - ComboRows(vm, title, sub, pool, blanks, onDone, spec.bool("preMeaning")) { picks, expected, revealed -> - spec.strOrNull("sample")?.let { Hint(it, vm.palette) } + ComboRows(vm, title, sub, blanks, onDone, spec.bool("preMeaning")) { picks, expected, revealed -> + spec.strOrNull("sample")?.let { Hint(it) } var i = 0 rows.forEachIndexed { ri, r -> val segs = r.strsOrEmpty("segs") @@ -582,25 +569,25 @@ private fun ComboEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - r.strOrNull("num")?.let { Text(it, color = vm.palette.text, fontWeight = FontWeight.SemiBold) } + r.strOrNull("num")?.let { InlineText(it) } segs.forEachIndexed { si, seg -> - if (!seg.isNullOrEmpty()) Text(seg, color = vm.palette.text) + if (!seg.isNullOrEmpty()) InlineText(seg) if (si < ans.size) { val idx = i++ ComboLine(vm, pool, picks, expected, idx, ans[si], revealed = revealed, inline = true) } } - meanings.getOrNull(ri)?.let { Text(vm.tr(it), color = vm.palette.subtext, fontSize = 12.sp) } + meanings.getOrNull(ri)?.let { Text(vm.tr(it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } if (revealed || spec.bool("preMeaning") || spec.bool("showGerman")) { - r.strOrNull("mean")?.let { Meaning(vm.tr(it), vm.palette, true) } + r.strOrNull("mean")?.let { Meaning(vm.tr(it), true) } } } } return } - ComboRows(vm, title, sub, pool, rows.size, onDone, spec.bool("preMeaning")) { picks, expected, revealed -> - spec.strOrNull("sample")?.let { Hint(it, vm.palette) } + ComboRows(vm, title, sub, rows.size, onDone, spec.bool("preMeaning")) { picks, expected, revealed -> + spec.strOrNull("sample")?.let { Hint(it) } rows.forEachIndexed { i, r -> val prefix = when (type) { "number" -> "${r.str("digits")} = " @@ -610,8 +597,7 @@ private fun ComboEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo else -> r.str("before") } val suffix = when (type) { - "seq" -> r.str("after") - "verbclue", "verb" -> r.str("after") + "seq", "verbclue", "verb" -> r.str("after") else -> "" } ComboLine(vm, pool, picks, expected, i, r.str("answer"), prefix, suffix, meanings.getOrNull(i) ?: r.strOrNull("mean"), revealed || spec.bool("preMeaning")) @@ -619,12 +605,16 @@ private fun ComboEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo } } +@Composable +private fun InlineText(text: String) { + Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(vertical = 8.dp)) +} + @Composable private fun ComboRows( vm: AppViewModel, title: String, sub: String?, - @Suppress("UNUSED_PARAMETER") pool: List, n: Int, onDone: () -> Unit, revealMean: Boolean = false, @@ -647,6 +637,7 @@ private fun ComboRows( } } +/** One blank: a chip that opens the word bank; prefix/suffix wrap around it. */ @OptIn(ExperimentalLayoutApi::class) @Composable private fun ComboLine( @@ -665,89 +656,42 @@ private fun ComboLine( SideEffect { if (idx in expected.indices && expected[idx] != answer) expected[idx] = answer } - val p = vm.palette var open by remember { mutableStateOf(false) } val pick = picks.getOrNull(idx).orEmpty() - val mark = if (revealed && pick.isNotEmpty()) { - answerAccepts(normalizeAnswer(pick), answer) - } else null + val mark = if (revealed && pick.isNotEmpty()) answerAccepts(normalizeAnswer(pick), answer) else null val chip = @Composable { - Box( - Modifier - .clip(RoundedCornerShape(12.dp)) - .background(p.mantle) - .border( - 1.dp, - when (mark) { - true -> p.success - false -> p.error - null -> p.text.copy(0.12f) - }, - RoundedCornerShape(12.dp), - ) - .clickable { open = true } - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Text( - pick.ifEmpty { "—" }, - style = appTextStyle(15.sp, color = if (pick.isEmpty()) p.overlay else p.text), - ) - } + AssistChip( + onClick = { open = true }, + label = { Text(pick.ifEmpty { "…" }, color = if (pick.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified) }, + trailingIcon = { + when (mark) { + true -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.width(18.dp)) + false -> Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.width(18.dp)) + null -> Icon(Icons.Filled.ArrowDropDown, null, modifier = Modifier.width(18.dp)) + } + }, + ) } if (inline) { chip() } else { FlowRow( - Modifier.fillMaxWidth().padding(bottom = 8.dp), + Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.Center, ) { - if (prefix.isNotBlank()) { - Text(prefix, style = appTextStyle(16.sp, color = p.text)) - } + if (prefix.isNotBlank()) InlineText(prefix) chip() - if (suffix.isNotBlank()) { - Text(suffix, style = appTextStyle(16.sp, color = p.text)) - } + if (suffix.isNotBlank()) InlineText(suffix) } } if (open) { - WordPicker(p, pool) { word -> + WordPicker(vm.tr("wordbank"), pool) { word -> if (word != null && idx in picks.indices) picks[idx] = word open = false } } - if (revealed && !meaning.isNullOrBlank()) Meaning(vm.tr(meaning), p, true) -} - -@Composable -private fun WordPicker(p: Palette, pool: List, onPick: (String?) -> Unit) { - Dialog( - onDismissRequest = { onPick(null) }, - properties = DialogProperties(usePlatformDefaultWidth = true), - ) { - Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(20.dp)) - .background(p.base) - .padding(10.dp) - .heightIn(max = 420.dp) - .verticalScroll(rememberScrollState()), - ) { - pool.forEach { w -> - Text( - w, - style = appTextStyle(16.sp, color = p.text), - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { onPick(w) } - .padding(horizontal = 14.dp, vertical = 12.dp), - ) - } - } - } + if (revealed && !meaning.isNullOrBlank()) Meaning(vm.tr(meaning), true) } @OptIn(ExperimentalLayoutApi::class) @@ -756,17 +700,18 @@ private fun Ex2Ex(vm: AppViewModel, title: String, sub: String?, spec: J, onDone val items = spec.arr("items") val pool = spec.strs("pool") val blanks = items.sumOf { it.arr("rows").sumOf { r -> r.strs("answers").size } } - ComboRows(vm, title, sub, pool, blanks, onDone) { picks, expected, revealed -> + ComboRows(vm, title, sub, blanks, onDone) { picks, expected, revealed -> var i = 0 - items.forEach { item -> - Meaning(item.str("czech"), vm.palette, true) + items.forEachIndexed { ii, item -> + if (ii > 0) HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Meaning(item.str("czech"), true) item.arr("rows").forEach { row -> val segs = row.strsOrEmpty("segs") val ans = row.strs("answers") FlowRow(verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - row.strOrNull("num")?.let { Text(it, color = vm.palette.text, modifier = Modifier.padding(end = 6.dp)) } + row.strOrNull("num")?.let { InlineText(it) } segs.forEachIndexed { si, seg -> - if (!seg.isNullOrEmpty()) Text(seg, color = vm.palette.text) + if (!seg.isNullOrEmpty()) InlineText(seg) if (si < ans.size) { val idx = i++ ComboLine(vm, pool, picks, expected, idx, ans[si], revealed = revealed, inline = true) @@ -782,12 +727,9 @@ private fun Ex2Ex(vm: AppViewModel, title: String, sub: String?, spec: J, onDone @Composable private fun LettersEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDone: () -> Unit) { val rows = spec.arr("rows") - val fields = remember { - mutableStateListOf(*rows.flatMap { it.strs("answers") }.map { "" }.toTypedArray()) - } + val fields = remember { mutableStateListOf(*rows.flatMap { it.strs("answers") }.map { "" }.toTypedArray()) } var fb by remember { mutableStateOf("" to "") } var revealed by remember { mutableStateOf(false) } - val p = vm.palette ExerciseScaffold(vm, title, sub, vm.tr("check"), { var i = 0 var ok = 0 @@ -803,26 +745,26 @@ private fun LettersEx(vm: AppViewModel, title: String, sub: String?, spec: J, on else fb = vm.tr("feedback_retry") to "err" revealed = true }, fb.first, fb.second) { - if (rows.any { it.strs("answers").any { a -> hasUmlaut(a) } }) Hint(vm.tr("hint_umlauts"), p) + if (rows.any { it.strs("answers").any { a -> hasUmlaut(a) } }) Hint(vm.tr("hint_umlauts")) var i = 0 rows.forEach { r -> - r.strOrNull("num")?.let { Text(it, color = p.subtext) } - FlowRow(verticalArrangement = Arrangement.Center) { + r.strOrNull("num")?.let { Text(it, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + FlowRow(verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(4.dp)) { val segs = r.strsOrEmpty("segs") val ans = r.strs("answers") segs.forEachIndexed { si, seg -> - if (!seg.isNullOrEmpty()) Text(seg, color = p.text) + if (!seg.isNullOrEmpty()) InlineText(seg) if (si < ans.size) { val idx = i++ - Box(Modifier.width(72.dp).padding(horizontal = 2.dp)) { - WordField(fields[idx], { fields[idx] = it }, p, "", + Box(Modifier.width(96.dp)) { + WordField(fields[idx], { fields[idx] = it; revealed = false }, "", if (revealed) answerAccepts(normalizeAnswer(fields[idx]), ans[si]) else null) } } } } - if (revealed) r.strOrNull("czech")?.let { Meaning(it, p, true) } - if (revealed) r.strOrNull("mean")?.let { Meaning(it, p, true) } + if (revealed) r.strOrNull("czech")?.let { Meaning(it, true) } + if (revealed) r.strOrNull("mean")?.let { Meaning(it, true) } Spacer(Modifier.height(8.dp)) } } @@ -836,7 +778,6 @@ private fun TableEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo val fields = remember { mutableStateListOf(*Array(nouns.size * persons.size) { "" }) } var fb by remember { mutableStateOf("" to "") } var revealed by remember { mutableStateOf(false) } - val p = vm.palette ExerciseScaffold(vm, title, sub, vm.tr("check"), { var ok = 0 answers.forEachIndexed { r, row -> @@ -850,11 +791,15 @@ private fun TableEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo revealed = true }, fb.first, fb.second) { nouns.forEachIndexed { r, noun -> - Text(noun, color = p.text, fontWeight = FontWeight.Bold) + Text(noun, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp)) persons.forEachIndexed { c, person -> val i = r * persons.size + c - Text(person, color = p.subtext, fontSize = 12.sp) - WordField(fields[i], { fields[i] = it }, p, "") + val ans = answers.getOrNull(r)?.getOrNull(c) ?: "" + Text(person, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp)) + WordField( + fields[i], { fields[i] = it; revealed = false }, "", + if (revealed) normalizeAnswer(fields[i]) == normalizeAnswer(ans) else null, + ) } Spacer(Modifier.height(8.dp)) } @@ -866,17 +811,16 @@ private fun RevealEx(vm: AppViewModel, title: String, sub: String?, spec: J, onD val items = spec.arr("items") val shown = remember { mutableStateListOf(*Array(items.size) { false }) } var fb by remember { mutableStateOf("" to "") } - val p = vm.palette ExerciseScaffold(vm, title, sub, vm.tr("finish"), { items.indices.forEach { shown[it] = true } fb = vm.tr("feedback_ok") to "ok" onDone() }, fb.first, fb.second) { - spec.strOrNull("note")?.let { Hint(it, p) } + spec.strOrNull("note")?.let { Hint(it) } items.forEachIndexed { i, it -> - Prompt(it.str("prompt"), p) - PillButton(vm.tr("show_sample"), p, Modifier.padding(vertical = 6.dp)) { shown[i] = !shown[i] } - if (shown[i]) Meaning(it.str("solution"), p, true) + Prompt(it.str("prompt")) + PillButton(vm.tr("show_sample"), Modifier.padding(vertical = 4.dp)) { shown[i] = !shown[i] } + if (shown[i]) Meaning(it.str("solution"), true) Spacer(Modifier.height(8.dp)) } } @@ -892,7 +836,7 @@ fun MluvExercise(vm: AppViewModel, n: Int) { fun NetQuizScreen(vm: AppViewModel, id: Int) { val lesson = vm.content.netLesson(id) ?: return if (id == 1) { - VlsmEx(vm, lesson) + VlsmEx(vm) return } val spec = J(org.json.JSONObject().apply { @@ -925,94 +869,103 @@ private class VlsmRow { } @Composable -private fun VlsmEx(vm: AppViewModel, @Suppress("UNUSED_PARAMETER") lesson: J) { +private fun VlsmEx(vm: AppViewModel) { val tasks = vm.content.netTasks val answers = vm.content.netAnswers val done = remember { mutableStateListOf(*Array(tasks.size) { false }) } - val rows = remember { - answers.map { block -> List(block.size) { VlsmRow() } } - } + val rows = remember { answers.map { block -> List(block.size) { VlsmRow() } } } var fb by remember { mutableStateOf("" to "") } - val p = vm.palette fun maybeFinish() { if (done.isNotEmpty() && done.all { it }) vm.markNet(1) } - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, vm.tr("net_ex_title"), null) - Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + DetailScaffold( + vm, vm.tr("net_ex_title"), null, + bottomBar = if (fb.first.isNotEmpty()) { + { + Box(Modifier.fillMaxWidth().padding(16.dp)) { FeedbackLine(fb.first, fb.second) } + } + } else null, + ) { inner -> + Column(Modifier.fillMaxSize().padding(inner).verticalScroll(rememberScrollState()).padding(16.dp)) { tasks.forEachIndexed { ti, task -> val block = answers.getOrNull(ti).orEmpty() val infeasible = block.isEmpty() || block.all { it.int("pfx") <= 0 } - Text(task.str("prompt"), color = p.text, modifier = Modifier.padding(vertical = 8.dp)) + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.Top) { + if (done[ti]) Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) + else Text("${ti + 1}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(12.dp)) + Text(task.str("prompt"), style = MaterialTheme.typography.bodyMedium) + } + } if (infeasible) { - Hint(vm.tr("net_infeasible"), p) + Hint(vm.tr("net_infeasible")) } else { - block.forEachIndexed { si, a -> + block.forEachIndexed { si, _ -> val field = rows.getOrNull(ti)?.getOrNull(si) ?: return@forEachIndexed - Text("Podsíť ${'A' + si}", color = p.subtext, fontWeight = FontWeight.SemiBold) - PrefixMenu(field.pfx, p) { field.pfx = it } - WordField(field.net, { field.net = it }, p, "síť") - WordField(field.bc, { field.bc = it }, p, "broadcast") - WordField(field.lo, { field.lo = it }, p, "první uzel") - WordField(field.hi, { field.hi = it }, p, "poslední uzel") - } - PillButton(vm.tr("check"), p, Modifier.padding(vertical = 6.dp)) { - val ok = block.indices.all { si -> - val a = block[si] - val field = rows[ti][si] - field.pfx.removePrefix("/").toIntOrNull() == a.int("pfx") && - netTxtEq(field.net, a.str("net")) && - netTxtEq(field.bc, a.str("bcast")) && - netTxtEq(field.lo, a.str("lo")) && - netTxtEq(field.hi, a.str("hi")) - } - if (ok) { - done[ti] = true - fb = vm.tr("feedback_ok") to "ok" - maybeFinish() - } else { - fb = vm.tr("feedback_retry") to "err" - } + Text("Podsíť ${'A' + si}", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp)) + PrefixMenu(field.pfx) { field.pfx = it } + WordField(field.net, { field.net = it }, "síť", numeric = true) + WordField(field.bc, { field.bc = it }, "broadcast", numeric = true) + WordField(field.lo, { field.lo = it }, "první uzel", numeric = true) + WordField(field.hi, { field.hi = it }, "poslední uzel", numeric = true) } } - PillButton(vm.tr("net_solution"), p) { + Row(Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (!infeasible) { - block.forEachIndexed { si, a -> - val field = rows[ti][si] - field.pfx = "/${a.int("pfx")}" - field.net = a.str("net") - field.bc = a.str("bcast") - field.lo = a.str("lo") - field.hi = a.str("hi") + PrimaryButton(vm.tr("check")) { + val ok = block.indices.all { si -> + val a = block[si] + val field = rows[ti][si] + field.pfx.removePrefix("/").toIntOrNull() == a.int("pfx") && + netTxtEq(field.net, a.str("net")) && + netTxtEq(field.bc, a.str("bcast")) && + netTxtEq(field.lo, a.str("lo")) && + netTxtEq(field.hi, a.str("hi")) + } + if (ok) { + done[ti] = true + fb = vm.tr("feedback_ok") to "ok" + maybeFinish() + } else { + fb = vm.tr("feedback_retry") to "err" + } } } - done[ti] = true - fb = "" to "" - maybeFinish() + PillButton(vm.tr("net_solution")) { + if (!infeasible) { + block.forEachIndexed { si, a -> + val field = rows[ti][si] + field.pfx = "/${a.int("pfx")}" + field.net = a.str("net") + field.bc = a.str("bcast") + field.lo = a.str("lo") + field.hi = a.str("hi") + } + } + done[ti] = true + fb = "" to "" + maybeFinish() + } } - if (done[ti]) Meaning(task.str("solution"), p, true) - Spacer(Modifier.height(12.dp)) + if (done[ti]) Meaning(task.str("solution"), true) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) } } - FeedbackLine(fb.first, fb.second, p) } } @Composable -private fun PrefixMenu(value: String, p: Palette, onPick: (String) -> Unit) { +private fun PrefixMenu(value: String, onPick: (String) -> Unit) { var open by remember { mutableStateOf(false) } - Box( - Modifier - .padding(vertical = 4.dp) - .clip(RoundedCornerShape(12.dp)) - .background(p.mantle) - .clickable { open = true } - .padding(10.dp), - ) { - Text(value.ifEmpty { "/…" }, style = appTextStyle(15.sp, color = p.text)) + OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text("Prefix", color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.weight(1f)) + Text(value.ifEmpty { "/…" }) + Icon(Icons.Filled.ArrowDropDown, null) } if (open) { - WordPicker(p, (8..30).map { "/$it" }) { word -> + WordPicker("Prefix", (8..30).map { "/$it" }) { word -> if (word != null) onPick(word) open = false } @@ -1026,51 +979,65 @@ fun LitQuizScreen(vm: AppViewModel, id: String) { if (qs.isEmpty()) return var idx by remember { mutableIntStateOf(0) } var score by remember { mutableIntStateOf(0) } - var answered by remember { mutableStateOf(false) } + var picked by remember { mutableIntStateOf(-1) } var finished by remember { mutableStateOf(false) } - val p = vm.palette - val kahoot = listOf(p.accent, p.error, p.warning, p.success) - Column(Modifier.fillMaxSize().padding(16.dp)) { - PageTop(vm, { vm.back() }, vm.tr(book.str("quizTitle")), vm.tr(book.str("quizSub"))) - if (finished) { - Text(vm.tr("lit_finished"), color = p.text, fontWeight = FontWeight.Bold, fontSize = 22.sp) - Text(vm.tr("lit_finished_text"), color = p.subtext) - Text(vm.fmt("lit_score_fmt", score, qs.size), color = p.text, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(12.dp)) - PrimaryButton(vm.tr("lit_again"), p) { idx = 0; score = 0; answered = false; finished = false } - } else { - val q = qs[idx] - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(vm.fmt("lit_question_fmt", idx + 1, qs.size), color = p.subtext) - Text(vm.fmt("lit_score_fmt", score, qs.size), color = p.subtext) - } - Text(q.str("prompt"), color = p.text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(vertical = 12.dp)) - q.strs("options").forEachIndexed { i, opt -> - Box( - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - .clip(RoundedCornerShape(16.dp)) - .background(kahoot[i % 4]) - .clickable(enabled = !answered) { - answered = true - if (i == q.int("correct")) score++ - } - .padding(16.dp), - ) { Text(opt, color = p.onAccent, fontWeight = FontWeight.SemiBold) } - } - if (answered) { - Meaning(q.str("expl"), p, true) - Spacer(Modifier.height(8.dp)) - PrimaryButton(if (idx + 1 >= qs.size) vm.tr("lit_show_result") else vm.tr("lit_next"), p) { + val answered = picked >= 0 + DetailScaffold( + vm, vm.tr(book.str("quizTitle")), vm.tr(book.str("quizSub")), + bottomBar = when { + finished -> ({ ActionBar(vm.tr("lit_again")) { idx = 0; score = 0; picked = -1; finished = false } }) + answered -> ({ + ActionBar(if (idx + 1 >= qs.size) vm.tr("lit_show_result") else vm.tr("lit_next")) { if (idx + 1 >= qs.size) { finished = true vm.markBookQuiz(id) } else { idx++ - answered = false + picked = -1 } } + }) + else -> null + }, + ) { inner -> + Column(Modifier.fillMaxSize().padding(inner).verticalScroll(rememberScrollState()).padding(16.dp)) { + if (finished) { + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) { + Column(Modifier.padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.EmojiEvents, null) + Spacer(Modifier.width(8.dp)) + Text(vm.tr("lit_finished"), style = MaterialTheme.typography.headlineSmall) + } + Spacer(Modifier.height(8.dp)) + Text(vm.tr("lit_finished_text"), style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + Text(vm.fmt("lit_score_fmt", score, qs.size), style = MaterialTheme.typography.titleMedium) + } + } + } else { + val q = qs[idx] + LinearProgressIndicator(progress = { idx / qs.size.toFloat() }, modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape)) + Spacer(Modifier.height(6.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(vm.fmt("lit_question_fmt", idx + 1, qs.size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(vm.fmt("lit_score_fmt", score, qs.size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text(q.str("prompt"), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 14.dp)) + q.strs("options").forEachIndexed { i, opt -> + val correct = i == q.int("correct") + val chosen = picked == i + OptionRow( + text = opt, + selected = chosen, + mark = if (answered) (if (correct) true else if (chosen) false else null) else null, + enabled = !answered, + ) { + picked = i + if (correct) score++ + } + } + if (answered) Meaning(q.str("expl"), true) } } } @@ -1087,4 +1054,4 @@ fun PlotScreen(vm: AppViewModel, id: String) { put("meanings", book.o.optJSONArray("plotMeaning")) }) DispatchExercise(vm, spec, vm.tr(book.str("plotTitle"))) { vm.markBookPlot(id) } -} +} \ No newline at end of file diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Icons.kt b/android/app/src/main/java/org/maturita/maturita/ui/Icons.kt index 735889e..63eea9f 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/Icons.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/Icons.kt @@ -51,6 +51,18 @@ fun StatsIcon(color: Color, iconSize: Dp = 18.dp) { } } +@Composable +fun SubjectsIcon(color: Color, iconSize: Dp = 22.dp) { + DrawnIcon(iconSize) { + val gap = size.minDimension * 0.08f + val cell = (size.minDimension - gap) / 2f + val r = androidx.compose.ui.geometry.CornerRadius(cell * 0.28f) + for (iy in 0 until 2) for (ix in 0 until 2) { + drawRoundRect(color, Offset(ix * (cell + gap), iy * (cell + gap)), Size(cell, cell), r) + } + } +} + @Composable fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) { DrawnIcon(iconSize) { diff --git a/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt b/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt deleted file mode 100644 index 595755a..0000000 --- a/android/app/src/main/java/org/maturita/maturita/ui/PathMap.kt +++ /dev/null @@ -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, - 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), - ) - } - } - } - } -} diff --git a/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt b/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt index f6779be..350587f 100644 --- a/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt +++ b/android/app/src/main/java/org/maturita/maturita/ui/Screens.kt @@ -1,40 +1,77 @@ package org.maturita.maturita.ui -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FormatListNumbered +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Quiz +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Lightbulb +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.geometry.Offset -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch import org.maturita.maturita.AppViewModel +import org.maturita.maturita.BuildConfig import org.maturita.maturita.Route import org.maturita.maturita.data.ColorMode import org.maturita.maturita.data.J @@ -46,267 +83,158 @@ import org.maturita.maturita.data.summarize import org.maturita.maturita.data.themeNames import org.maturita.maturita.data.themePalette +// --------------------------------------------------------------------------- +// Roots + @Composable -fun WelcomeScreen(vm: AppViewModel) { - val p = vm.palette - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 16.dp), - ) { - Text("maturita.c", style = appTextStyle(15.sp, FontWeight.Medium, color = p.text)) - Text( - "sestavení ${org.maturita.maturita.BuildConfig.VERSION_NAME} · ${org.maturita.maturita.BuildConfig.COMMIT.take(7)}", - style = appTextStyle(12.sp, color = p.overlay), - ) - Spacer(Modifier.height(12.dp)) - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(vm.tr("welcome_title"), style = appTextStyle(32.sp, FontWeight.Medium, color = p.text)) - Spacer(Modifier.height(12.dp)) - Text( - vm.tr("welcome_body_android"), - color = p.subtext, - fontSize = 16.sp, - ) - Spacer(Modifier.height(22.dp)) - PrimaryButton(vm.tr("continue"), p) { vm.go(Route.Subjects) } - } - Spacer(Modifier.width(12.dp)) - Box( - Modifier - .size(108.dp) - .clip(CircleShape) - .background(p.mantle) - .border(1.dp, p.text.copy(0.08f), CircleShape), - contentAlignment = Alignment.Center, +fun HomeScreen(vm: AppViewModel) { + val sum = summarize(vm.content, vm.progress) + val pct = if (sum.totalEx == 0) 0f else sum.doneEx / sum.totalEx.toFloat() + RootScaffold(vm, "Maturita") { + item { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(vm.tr("welcome_stat_value"), color = p.text, fontSize = 28.sp, fontWeight = FontWeight.Bold) - Text(vm.tr("welcome_stat_label"), color = p.subtext, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center) + Column(Modifier.padding(20.dp)) { + Text( + vm.tr("welcome_title"), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(Modifier.height(8.dp)) + Text( + vm.tr("welcome_body_android"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { pct }, + modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape), + color = MaterialTheme.colorScheme.onPrimaryContainer, + trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.18f), + ) + Spacer(Modifier.height(6.dp)) + Text( + vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) } } } - Spacer(Modifier.height(28.dp)) - FeatureCard(vm, "welcome_feat1_title", "welcome_feat1_body", "welcome_feat1_link") - Spacer(Modifier.height(12.dp)) - FeatureCard(vm, "welcome_feat2_title", "welcome_feat2_body", "welcome_feat2_link") - } -} - -@Composable -private fun FeatureCard(vm: AppViewModel, t: String, b: String, l: String) { - val p = vm.palette - CardBox(p, Modifier.fillMaxWidth()) { - Column { - Text(vm.tr(t), color = p.text, fontSize = 18.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(6.dp)) - Text(vm.tr(b), color = p.subtext, fontSize = 15.sp) - Spacer(Modifier.height(10.dp)) - PillButton(vm.tr(l), p) { vm.go(Route.Subjects) } + if (vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged") { + item { UpdateBanner(vm) } + } + item { SectionHeader(vm.tr("subjects_title")) } + items(vm.content.subjects) { s -> + val open = s.bool("open") + NavRow( + title = vm.tr(s.str("key")), + subtitle = if (open) null else vm.tr("stats_locked"), + locked = !open, + leading = { SubjectIcon(s.str("icon"), open) }, + onClick = if (open) ({ vm.goPage(s.str("target")) }) else null, + ) } } } @Composable -fun SubjectsScreen(vm: AppViewModel) { - val p = vm.palette - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, vm.tr("subjects_title"), vm.tr("subjects_sub")) - Column( - Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - vm.content.subjects.chunked(2).forEach { row -> - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - row.forEach { s -> - val open = s.bool("open") - Box( - Modifier - .weight(1f) - .height(132.dp) - .clip(RoundedCornerShape(20.dp)) - .background(p.mantle) - .border(1.dp, if (open) p.text.copy(0.08f) else p.text.copy(0.04f), RoundedCornerShape(20.dp)) - .alpha(if (open) 1f else 0.48f) - .then(if (open) Modifier.clickable { vm.goPage(s.str("target")) } else Modifier) - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - when (s.str("icon")) { - "de" -> GermanFlag(44.dp) - "wifi" -> WifiIcon(p.text, 36.dp) - "chip" -> ChipIcon(p.text, 36.dp) - "cz" -> CzechFlag(44.dp) - else -> LockIcon(p.overlay, 22.dp) - } - Spacer(Modifier.height(10.dp)) - Text(vm.tr(s.str("key")), color = p.text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center) - } - } - } - if (row.size == 1) Spacer(Modifier.weight(1f)) - } - } - } - } -} - -@Composable -fun SettingsSheet(vm: AppViewModel) { - val p = vm.palette - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(20.dp), +private fun UpdateBanner(vm: AppViewModel) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { Text( - vm.tr("settings_title"), - color = p.text, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, + vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())), + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) - PillButton(vm.tr("back"), p) { vm.settingsOpen = false } - } - Spacer(Modifier.height(16.dp)) - Text(vm.tr("mode"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(6.dp)) - Segmented(p) { - SegChip(vm.tr("mode_dark"), vm.mode == ColorMode.Dark, p, Modifier.weight(1f)) { vm.applyMode(ColorMode.Dark) } - SegChip(vm.tr("mode_light"), vm.mode == ColorMode.Light, p, Modifier.weight(1f)) { vm.applyMode(ColorMode.Light) } - } - Spacer(Modifier.height(16.dp)) - Text(vm.tr("theme"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(8.dp)) - themeNames.forEachIndexed { i, name -> - val id = ThemeId.entries.getOrNull(i) ?: return@forEachIndexed - val sw = themePalette(id, vm.mode) - val sel = vm.themeId == id - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(if (sel) p.surface1 else p.surface0) - .clickable { vm.setTheme(id) } - .padding(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Canvas(Modifier.size(36.dp)) { - drawCircle(sw.base) - drawCircle(sw.accent, 5f, Offset(size.width * 0.28f, size.height * 0.5f)) - drawCircle(sw.accent3, 5f, Offset(size.width * 0.5f, size.height * 0.5f)) - drawCircle(sw.success, 5f, Offset(size.width * 0.72f, size.height * 0.5f)) - if (sel) drawCircle(p.accent, size.minDimension / 2 - 1f, style = androidx.compose.ui.graphics.drawscope.Stroke(2.2f)) - } - Spacer(Modifier.width(10.dp)) - Text(name, color = p.text, fontWeight = FontWeight.Medium) + if (vm.update.canInstall) { + Spacer(Modifier.width(8.dp)) + FilledTonalButton(onClick = { vm.installUpdate() }) { Text(vm.tr("update_install")) } } - Spacer(Modifier.height(6.dp)) } - Spacer(Modifier.height(10.dp)) - Text(vm.tr("language"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(6.dp)) - Segmented(p) { - SegChip("Čeština", vm.lang == UiLang.Cs, p, Modifier.weight(1f)) { vm.applyLang(UiLang.Cs) } - SegChip("English", vm.lang == UiLang.En, p, Modifier.weight(1f)) { vm.applyLang(UiLang.En) } + } +} + +@Composable +private fun SubjectIcon(icon: String, open: Boolean) { + val tint = if (open) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline + Box(Modifier.size(32.dp), contentAlignment = Alignment.Center) { + when (icon) { + "de" -> GermanFlag(28.dp) + "wifi" -> WifiIcon(tint, 26.dp) + "chip" -> ChipIcon(tint, 26.dp) + "cz" -> CzechFlag(28.dp) + else -> Icon(Icons.Filled.Lock, null, tint = tint) } - Spacer(Modifier.height(16.dp)) - Text(vm.tr("updates"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(6.dp)) - Text( - vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())), - color = p.subtext, - fontSize = 14.sp, - ) - Spacer(Modifier.height(10.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - PillButton(vm.tr("update_check"), p) { vm.checkUpdate(true) } - if (vm.update.canInstall) PrimaryButton(vm.tr("update_install"), p) { vm.installUpdate() } - } - Spacer(Modifier.height(24.dp)) } } @Composable fun StatsScreen(vm: AppViewModel) { - val p = vm.palette val sum = summarize(vm.content, vm.progress) - val pct = if (sum.totalEx == 0) 0 else (100 * sum.doneEx / sum.totalEx) - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, vm.tr("stats_title"), vm.tr("stats_sub")) - Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Metric(p, vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx), Modifier.weight(1f)) - Metric(p, vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", pct), Modifier.weight(1f)) - Metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits), Modifier.weight(1f)) - } - Spacer(Modifier.height(10.dp)) - ProgressBar(pct / 100f, p, 8.dp) - Spacer(Modifier.height(20.dp)) - Text(vm.tr("stats_section"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(8.dp)) - vm.content.subjects.forEach { s -> - val open = s.bool("open") - val part = subjectSum(vm, s) - CardBox(p, Modifier.fillMaxWidth().padding(bottom = 10.dp).alpha(if (open) 1f else 0.55f)) { - Column { - Text(vm.tr(s.str("key")), color = p.text, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(4.dp)) - Text( - if (open) vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) else vm.tr("stats_locked"), - color = p.subtext, - fontSize = 13.sp, - ) - if (open && part.totalEx > 0) { - Spacer(Modifier.height(8.dp)) - ProgressBar(part.doneEx / part.totalEx.toFloat(), p, 6.dp) - } + val pct = if (sum.totalEx == 0) 0f else sum.doneEx / sum.totalEx.toFloat() + RootScaffold(vm, vm.tr("stats_title")) { + item { + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Column(Modifier.padding(20.dp)) { + Row(Modifier.fillMaxWidth()) { + Metric(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx), Modifier.weight(1f)) + Metric(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", (pct * 100).toInt()), Modifier.weight(1f)) + Metric(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits), Modifier.weight(1f)) } + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator(progress = { pct }, modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape)) } } } - } -} - -@Composable -private fun ProgressBar(fraction: Float, p: org.maturita.maturita.data.Palette, barHeight: androidx.compose.ui.unit.Dp) { - Box( - Modifier - .fillMaxWidth() - .height(barHeight) - .clip(CircleShape) - .background(p.surface1), - ) { - Box( - Modifier - .fillMaxHeight() - .fillMaxWidth(fraction.coerceIn(0f, 1f)) - .background(p.accent), - ) - } -} - -@Composable -private fun Metric(p: org.maturita.maturita.data.Palette, label: String, value: String, modifier: Modifier) { - CardBox(p, modifier) { - Column { - Text(label, color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - Text(value, color = p.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) + item { SectionHeader(vm.tr("stats_section")) } + items(vm.content.subjects) { s -> + val open = s.bool("open") + val part = subjectSum(vm, s) + ListItem( + headlineContent = { + Text(vm.tr(s.str("key")), color = if (open) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant) + }, + supportingContent = { + Column { + if (open && part.totalEx > 0) { + Spacer(Modifier.height(6.dp)) + LinearProgressIndicator( + progress = { part.doneEx / part.totalEx.toFloat() }, + modifier = Modifier.fillMaxWidth().height(4.dp).clip(CircleShape), + ) + } + } + }, + leadingContent = { SubjectIcon(s.str("icon"), open) }, + trailingContent = { + Text( + if (open) vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) else vm.tr("stats_locked"), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) } } } +@Composable +private fun Metric(label: String, value: String, modifier: Modifier) { + Column(modifier) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.titleLarge) + } +} + private fun subjectSum(vm: AppViewModel, s: J): ProgressSum { - val key = s.str("key") val sum = ProgressSum() when (s.str("target")) { "roadmap" -> vm.content.german.filter { it.bool("unlocked") }.forEach { u -> @@ -330,55 +258,144 @@ private fun subjectSum(vm: AppViewModel, s: J): ProgressSum { return sum } +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SearchOverlay(vm: AppViewModel) { - val p = vm.palette - val q = normalizeAnswer(vm.searchQuery) - val hits = buildSearch(vm).filter { q.isEmpty() || normalizeAnswer(it.hay).contains(q) }.take(24) - Column( - Modifier - .fillMaxSize() - .background(p.base.copy(alpha = 0.96f)) - .padding(16.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier - .weight(1f) - .clip(RoundedCornerShape(16.dp)) - .background(p.mantle) - .padding(14.dp), - ) { - if (vm.searchQuery.isEmpty()) Text(vm.tr("search_placeholder"), color = p.overlay) - BasicTextField( - vm.searchQuery, - { vm.searchQuery = it }, - textStyle = TextStyle(color = p.text, fontSize = 16.sp), - cursorBrush = SolidColor(p.accent), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) +fun SettingsScreen(vm: AppViewModel) { + RootScaffold(vm, vm.tr("settings_title"), showSearch = false) { + item { SectionHeader(vm.tr("mode")) } + item { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + SegmentedButton( + selected = vm.mode == ColorMode.Light, + onClick = { vm.applyMode(ColorMode.Light) }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { Text(vm.tr("mode_light")) } + SegmentedButton( + selected = vm.mode == ColorMode.Dark, + onClick = { vm.applyMode(ColorMode.Dark) }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { Text(vm.tr("mode_dark")) } } - Spacer(Modifier.width(8.dp)) - PillButton(vm.tr("back"), p) { vm.searchOpen = false } } - Spacer(Modifier.height(12.dp)) - if (hits.isEmpty()) { - Text(if (q.isEmpty()) vm.tr("search_hint") else vm.tr("search_empty"), color = p.subtext) - } else { - Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { - hits.forEach { hit -> - CardBox(p, Modifier.fillMaxWidth().clickable { - if (!hit.locked) { - vm.goPage(hit.target) - vm.searchOpen = false - } - }.alpha(if (hit.locked) 0.5f else 1f)) { - Column { - Text(hit.title, color = p.text, fontWeight = FontWeight.SemiBold) - Text(hit.sub, color = p.subtext, fontSize = 13.sp) - } + item { SectionHeader(vm.tr("theme")) } + items(ThemeId.entries.size) { i -> + val id = ThemeId.entries[i] + val sw = themePalette(id, vm.mode) + val sel = vm.themeId == id + ListItem( + headlineContent = { Text(themeNames[i]) }, + leadingContent = { + Box(Modifier.size(28.dp).clip(CircleShape).background(sw.base), contentAlignment = Alignment.Center) { + Box(Modifier.size(14.dp).clip(CircleShape).background(sw.tint)) } + }, + trailingContent = { RadioButton(selected = sel, onClick = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { vm.setTheme(id) }, + ) + } + item { SectionHeader(vm.tr("language")) } + item { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + SegmentedButton( + selected = vm.lang == UiLang.Cs, + onClick = { vm.applyLang(UiLang.Cs) }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { Text("Čeština") } + SegmentedButton( + selected = vm.lang == UiLang.En, + onClick = { vm.applyLang(UiLang.En) }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { Text("English") } + } + } + item { SectionHeader(vm.tr("updates")) } + item { + Column(Modifier.padding(horizontal = 16.dp)) { + Text( + vm.fmt(vm.update.messageKey, *(listOfNotNull(vm.update.messageArg).toTypedArray())), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { vm.checkUpdate(true) }) { Text(vm.tr("update_check")) } + if (vm.update.canInstall) { + PrimaryButton(vm.tr("update_install")) { vm.installUpdate() } + } + } + } + } + item { + ListItem( + headlineContent = { Text("Build") }, + supportingContent = { Text("${BuildConfig.VERSION_NAME} · ${BuildConfig.COMMIT.take(7)}") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} + +// --------------------------------------------------------------------------- +// Search + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen(vm: AppViewModel) { + val q = normalizeAnswer(vm.searchQuery) + val hits = buildSearch(vm).filter { q.isEmpty() || normalizeAnswer(it.hay).contains(q) }.take(40) + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + Scaffold( + topBar = { + Surface(color = MaterialTheme.colorScheme.surfaceContainer) { + Row( + Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { vm.searchOpen = false }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = vm.tr("back")) + } + TextField( + value = vm.searchQuery, + onValueChange = { vm.searchQuery = it }, + placeholder = { Text(vm.tr("search_placeholder")) }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + modifier = Modifier.weight(1f).focusRequester(focus), + ) + if (vm.searchQuery.isNotEmpty()) { + IconButton(onClick = { vm.searchQuery = "" }) { Icon(Icons.Filled.Close, contentDescription = null) } + } else { + Icon(Icons.Filled.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp)) + } + } + } + }, + ) { inner -> + if (hits.isEmpty()) { + Text( + if (q.isEmpty()) vm.tr("search_hint") else vm.tr("search_empty"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(inner).padding(24.dp), + ) + } else { + LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 24.dp)) { + items(hits) { hit -> + NavRow( + title = hit.title, + subtitle = hit.sub, + locked = hit.locked, + leading = { Icon(Icons.Filled.Search, null, tint = MaterialTheme.colorScheme.outline) }, + onClick = if (hit.locked) null else ({ vm.openPage(hit.target) }), + ) } } } @@ -394,7 +411,7 @@ private fun buildSearch(vm: AppViewModel): List { } add(vm.tr("subjects_title"), vm.tr("search_page"), "subjects", extra = "predmety") add(vm.tr("stats"), vm.tr("search_page"), "stats") - add(vm.tr("search_home"), vm.tr("search_page"), "welcome") + add(vm.tr("settings"), vm.tr("search_page"), "settings") vm.content.subjects.forEach { s -> add(vm.tr(s.str("key")), vm.tr("search_subject"), s.strOrNull("target") ?: "subjects", !s.bool("open")) } @@ -417,6 +434,7 @@ private fun buildSearch(vm: AppViewModel): List { add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "hwex${l.int("id")}") } add(vm.tr("Mluvnice"), vm.tr("search_lesson"), "mluvnice") + vm.content.mluvnice.forEach { m -> add(m.str("name"), vm.tr("Mluvnice"), "mluve${m.int("id")}") } add(vm.tr("Maturitní četba"), vm.tr("search_book"), "readinglist") vm.content.books.forEach { b -> add(b.str("title"), vm.tr("search_book"), b.str("page")) @@ -424,67 +442,62 @@ private fun buildSearch(vm: AppViewModel): List { return out } +// --------------------------------------------------------------------------- +// Lists + +data class LessonRow(val title: String, val done: Boolean, val route: Route, val subtitle: String? = null) + +@Composable +fun LessonListScreen(vm: AppViewModel, title: String, sub: String, rows: List) { + DetailScaffold(vm, title, sub) { inner -> + LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { + items(rows) { row -> + NavRow(title = row.title, subtitle = row.subtitle, done = row.done, onClick = { vm.go(row.route) }) + } + } + } +} + @Composable fun RoadmapScreen(vm: AppViewModel) { - val p = vm.palette - val current = vm.content.german.indexOfFirst { u -> - u.bool("unlocked") && u.strs("names").indices.any { !vm.progress.germanDone(u.int("id"), it + 1) } - } - Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { - PageTop(vm, { vm.back() }, vm.tr("roadmap_title"), vm.tr("roadmap_sub")) - PathMap( - modifier = Modifier.weight(1f), - nodes = vm.content.german.mapIndexed { i, u -> + DetailScaffold(vm, vm.tr("roadmap_title"), vm.tr("roadmap_sub")) { inner -> + LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { + items(vm.content.german) { u -> val unlocked = u.bool("unlocked") val names = u.strs("names") - val done = unlocked && names.isNotEmpty() && names.indices.all { vm.progress.germanDone(u.int("id"), it + 1) } - MapNode( - id = u.str("page"), - label = u.str("title"), - locked = !unlocked, + val doneCount = names.indices.count { vm.progress.germanDone(u.int("id"), it + 1) } + val done = unlocked && names.isNotEmpty() && doneCount == names.size + NavRow( + title = u.str("title"), + subtitle = if (unlocked) vm.fmt("stats_ex_fmt", doneCount, names.size) else null, done = done, - current = i == current, - finish = i == vm.content.german.lastIndex, + locked = !unlocked, onClick = if (unlocked) ({ vm.go(Route.UnitMap(u.int("id"))) }) else null, ) - }, - palette = p, - litUntil = 2f, - ) + } + } } } @Composable fun UnitMapScreen(vm: AppViewModel, unitId: Int) { val u = vm.content.germanUnit(unitId) ?: return - val p = vm.palette val names = u.strs("names") - Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { - PageTop(vm, { vm.back() }, u.str("title"), vm.tr(u.str("sub"))) - val next = names.indices.firstOrNull { !vm.progress.germanDone(unitId, it + 1) } - PathMap( - modifier = Modifier.weight(1f), - nodes = names.mapIndexed { i, name -> - val n = i + 1 - MapNode( - id = "e$n", - label = name, - locked = false, - done = vm.progress.germanDone(unitId, n), - current = i == next, - onClick = { vm.go(Route.GermanEx(unitId, n)) }, - ) - }, - palette = p, - litUntil = names.indices.count { vm.progress.germanDone(unitId, it + 1) }.toFloat().coerceAtMost((names.size - 1).toFloat()), - nodeSize = 58f, - spac = 140f, - mx = 70f, - lift = if (u.has("branch")) 110f else 0f, - ) - if (u.has("branch")) { - Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) { - PrimaryButton(u.str("vocab").let { vm.tr("Vokabeltraining") }, p) { vm.go(Route.Vocab(unitId)) } + DetailScaffold(vm, u.str("title"), vm.tr(u.str("sub"))) { inner -> + LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { + items(names.size) { i -> + NavRow(title = names[i], done = vm.progress.germanDone(unitId, i + 1)) { vm.go(Route.GermanEx(unitId, i + 1)) } + } + if (u.has("branch")) { + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + item { + NavRow( + title = vm.tr("Vokabeltraining"), + subtitle = vm.tr("sub_translate"), + done = vm.progress.vocabDone(unitId), + leading = { Icon(Icons.AutoMirrored.Filled.MenuBook, null, tint = MaterialTheme.colorScheme.primary) }, + ) { vm.go(Route.Vocab(unitId)) } + } } } } @@ -492,105 +505,36 @@ fun UnitMapScreen(vm: AppViewModel, unitId: Int) { @Composable fun NetYearsScreen(vm: AppViewModel) { - val p = vm.palette - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, vm.tr("net_years_title"), vm.tr("net_years_sub")) - Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { - YearCard(vm, vm.tr("net_year1"), vm.tr("net_sub"), true) { vm.go(Route.NetMap) } - listOf("net_year2", "net_year3", "net_year4").forEach { key -> - YearCard(vm, vm.tr(key), vm.tr("net_year_locked_sub"), false) {} + DetailScaffold(vm, vm.tr("net_years_title"), vm.tr("net_years_sub")) { inner -> + LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { + item { + NavRow(title = vm.tr("net_year1"), subtitle = vm.tr("net_sub"), leading = { Text("1.", style = MaterialTheme.typography.titleMedium) }) { vm.go(Route.NetMap) } } - Spacer(Modifier.height(16.dp)) - } - } -} - -@Composable -private fun YearCard(vm: AppViewModel, title: String, sub: String, open: Boolean, onClick: () -> Unit) { - val p = vm.palette - CardBox(p, Modifier.fillMaxWidth().alpha(if (open) 1f else 0.48f).then(if (open) Modifier.clickable(onClick = onClick) else Modifier)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(title, color = p.text, fontWeight = FontWeight.Bold, fontSize = 18.sp) - Text(sub, color = p.subtext, fontSize = 14.sp) + items(listOf("net_year2", "net_year3", "net_year4")) { key -> + NavRow(title = vm.tr(key), subtitle = vm.tr("net_year_locked_sub"), locked = true, onClick = null) } - if (!open) LockIcon(p.overlay) - } - } -} - -@Composable -fun LessonMapScreen( - vm: AppViewModel, - title: String, - sub: String, - nodes: List, - litUntil: Float, -) { - Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { - PageTop(vm, { vm.back() }, title, sub) - PathMap(nodes, vm.palette, litUntil, modifier = Modifier.weight(1f), nodeSize = 64f, spac = 150f, mx = 70f) - } -} - -@Composable -fun SlidesScreen( - vm: AppViewModel, - title: String, - sub: String, - slides: List, - onExercise: () -> Unit, -) { - val p = vm.palette - var idx = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(0) } - val slide = slides.getOrNull(idx.intValue) ?: return - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, title, sub) - Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - Text(slide.str("kicker"), color = p.subtext, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - Text(slide.str("title"), color = p.text, fontSize = 24.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)) - slide.strOrNull("tip")?.let { CardBox(p, Modifier.fillMaxWidth().padding(bottom = 12.dp)) { Text(it, color = p.text) } } - slide.strs("lines").forEach { line -> - CardBox(p, Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - Text(line, color = p.text, fontSize = 16.sp) - } - } - } - Row(Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween) { - if (idx.intValue > 0) PillButton(vm.tr("net_slide_prev"), p) { idx.intValue-- } - else Spacer(Modifier.width(1.dp)) - if (idx.intValue < slides.lastIndex) PrimaryButton(vm.tr("net_slide_next"), p) { idx.intValue++ } - else PrimaryButton(vm.tr("net_slide_start"), p, onClick = onExercise) } } } @Composable fun CzechMapScreen(vm: AppViewModel) { - val p = vm.palette - val items = listOf( - Triple("L", vm.tr("Literatura"), null), - Triple("M", vm.tr("Mluvnice"), Route.Mluvnice as Route?), - Triple("Č", vm.tr("Maturitní četba"), Route.ReadingList as Route?), - ) - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, vm.tr("Český jazyk a literatura"), vm.tr("czech_sub")) - Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { - items.forEach { (letter, name, route) -> - CardBox(p, Modifier.fillMaxWidth().alpha(if (route == null) 0.48f else 1f).then( - if (route != null) Modifier.clickable { vm.go(route) } else Modifier - )) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(56.dp).clip(CircleShape).background(if (route == null) p.lockedBg else p.accent), - contentAlignment = Alignment.Center, - ) { - if (route == null) LockIcon(p.overlay) else Text(letter, color = p.onAccent, fontWeight = FontWeight.Black, fontSize = 20.sp) - } - Spacer(Modifier.width(14.dp)) - Text(name, color = p.text, fontWeight = FontWeight.SemiBold, fontSize = 16.sp) - } - } + DetailScaffold(vm, vm.tr("Český jazyk a literatura"), vm.tr("czech_sub")) { inner -> + LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { + item { NavRow(title = vm.tr("Literatura"), locked = true, onClick = null) } + item { + NavRow( + title = vm.tr("Mluvnice"), + subtitle = vm.tr("mluv_sub"), + leading = { Icon(Icons.Filled.FormatListNumbered, null, tint = MaterialTheme.colorScheme.primary) }, + ) { vm.go(Route.Mluvnice) } + } + item { + NavRow( + title = vm.tr("Maturitní četba"), + subtitle = vm.tr("reading_sub"), + leading = { Icon(Icons.AutoMirrored.Filled.MenuBook, null, tint = MaterialTheme.colorScheme.primary) }, + ) { vm.go(Route.ReadingList) } } } } @@ -598,21 +542,16 @@ fun CzechMapScreen(vm: AppViewModel) { @Composable fun BookListScreen(vm: AppViewModel) { - val p = vm.palette - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, vm.tr("Maturitní četba"), vm.tr("reading_sub")) - Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { - vm.content.books.forEach { b -> - CardBox(p, Modifier.fillMaxWidth().clickable { vm.go(Route.Book(b.str("id"))) }) { - Row(verticalAlignment = Alignment.CenterVertically) { - BookIcon(p.text, 28.dp) - Spacer(Modifier.width(12.dp)) - Column { - Text(b.str("title"), color = p.text, fontWeight = FontWeight.Bold) - Text(vm.tr(b.str("subKey")), color = p.subtext, fontSize = 13.sp) - } - } - } + DetailScaffold(vm, vm.tr("Maturitní četba"), vm.tr("reading_sub")) { inner -> + LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { + items(vm.content.books) { b -> + val id = b.str("id") + NavRow( + title = b.str("title"), + subtitle = vm.tr(b.str("subKey")), + done = vm.progress.bookQuiz(id) && vm.progress.bookPlot(id), + leading = { Icon(Icons.AutoMirrored.Filled.MenuBook, null, tint = MaterialTheme.colorScheme.primary) }, + ) { vm.go(Route.Book(id)) } } } } @@ -621,39 +560,31 @@ fun BookListScreen(vm: AppViewModel) { @Composable fun BookScreen(vm: AppViewModel, id: String) { val b = vm.content.book(id) ?: return - val p = vm.palette - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - PageTop(vm, { vm.back() }, b.str("title"), vm.tr(b.str("subKey"))) - Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - CardBox(p, Modifier.fillMaxWidth().clickable { vm.go(Route.BookQuiz(id)) }) { - Row(verticalAlignment = Alignment.CenterVertically) { - QuizIcon(p.text); Spacer(Modifier.width(10.dp)) - Column { - Text(vm.tr(b.str("quizTitle")), color = p.text, fontWeight = FontWeight.Bold) - Text(vm.tr(b.str("quizSub")), color = p.subtext, fontSize = 13.sp) - } - } + DetailScaffold(vm, b.str("title"), vm.tr(b.str("subKey"))) { inner -> + LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { + item { + NavRow( + title = vm.tr(b.str("quizTitle")), + subtitle = vm.tr(b.str("quizSub")), + done = vm.progress.bookQuiz(id), + leading = { Icon(Icons.Filled.Quiz, null, tint = MaterialTheme.colorScheme.primary) }, + ) { vm.go(Route.BookQuiz(id)) } } - CardBox(p, Modifier.fillMaxWidth().clickable { vm.go(Route.BookPlot(id)) }) { - Row(verticalAlignment = Alignment.CenterVertically) { - OrderIcon(p.text); Spacer(Modifier.width(10.dp)) - Column { - Text(vm.tr(b.str("plotTitle")), color = p.text, fontWeight = FontWeight.Bold) - Text(vm.tr(b.str("plotSub")), color = p.subtext, fontSize = 13.sp) - } - } + item { + NavRow( + title = vm.tr(b.str("plotTitle")), + subtitle = vm.tr(b.str("plotSub")), + done = vm.progress.bookPlot(id), + leading = { Icon(Icons.Filled.FormatListNumbered, null, tint = MaterialTheme.colorScheme.primary) }, + ) { vm.go(Route.BookPlot(id)) } } - b.arr("notes").forEach { note -> - CardBox(p, Modifier.fillMaxWidth()) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - noteIcon(note.str("icon"), p.text)() - Spacer(Modifier.width(8.dp)) - Text(note.str("title"), color = p.text, fontWeight = FontWeight.Bold) - } - Spacer(Modifier.height(8.dp)) - note.strs("lines").forEach { - Text("• $it", color = p.text, fontSize = 15.sp, modifier = Modifier.padding(bottom = 4.dp)) + items(b.arr("notes")) { note -> + SectionHeader(note.str("title")) + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Column(Modifier.padding(16.dp)) { + note.strs("lines").forEachIndexed { i, line -> + if (i > 0) HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Text(line, style = MaterialTheme.typography.bodyMedium) } } } @@ -661,3 +592,87 @@ fun BookScreen(vm: AppViewModel, id: String) { } } } + +// --------------------------------------------------------------------------- +// Lesson slides + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SlidesScreen( + vm: AppViewModel, + title: String, + sub: String, + slides: List, + onExercise: () -> Unit, +) { + if (slides.isEmpty()) return + val pager = rememberPagerState { slides.size } + val scope = rememberCoroutineScope() + val last = pager.currentPage >= slides.lastIndex + DetailScaffold( + vm, title, sub, + bottomBar = { + Column { + Row( + Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + slides.indices.forEach { i -> + val active = i == pager.currentPage + Box( + Modifier + .padding(horizontal = 3.dp) + .size(width = if (active) 18.dp else 6.dp, height = 6.dp) + .clip(CircleShape) + .background(if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHighest), + ) + } + } + ActionBar( + label = if (last) vm.tr("net_slide_start") else vm.tr("net_slide_next"), + leading = if (pager.currentPage > 0) { + { + OutlinedButton( + onClick = { scope.launch { pager.animateScrollToPage(pager.currentPage - 1) } }, + modifier = Modifier.height(50.dp), + ) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = vm.tr("net_slide_prev")) } + } + } else null, + ) { + if (last) onExercise() else scope.launch { pager.animateScrollToPage(pager.currentPage + 1) } + } + } + }, + ) { inner -> + HorizontalPager(state = pager, modifier = Modifier.fillMaxSize().padding(inner)) { page -> + val slide = slides[page] + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text(slide.str("kicker"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(slide.str("title"), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 4.dp, bottom = 12.dp)) + slide.strOrNull("tip")?.let { tip -> + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Lightbulb, null, tint = MaterialTheme.colorScheme.onSecondaryContainer) + Spacer(Modifier.width(10.dp)) + Text(tip, style = MaterialTheme.typography.bodyMedium) + } + } + } + slide.strs("lines").forEach { line -> + Text(line, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(vertical = 6.dp)) + } + Spacer(Modifier.height(24.dp)) + } + } + } + LaunchedEffect(slides) { if (pager.currentPage > slides.lastIndex) pager.scrollToPage(0) } +} diff --git a/data/style.css b/data/style.css index ca0fed2..ce09873 100644 --- a/data/style.css +++ b/data/style.css @@ -926,7 +926,7 @@ flowboxchild { /* ------------------------------------------------------------------ */ .stats-page { - padding: 8px 28px 24px 28px; + padding: 24px 32px 24px 32px; } .stats-summary { margin-top: 10px; diff --git a/ios/Maturita.xcodeproj/project.pbxproj b/ios/Maturita.xcodeproj/project.pbxproj index 9797edf..34f934c 100644 --- a/ios/Maturita.xcodeproj/project.pbxproj +++ b/ios/Maturita.xcodeproj/project.pbxproj @@ -18,7 +18,6 @@ A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; }; A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; }; A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.swift */; }; - A101000000000000000012 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000012 /* PathMap.swift */; }; A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; }; A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; }; A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.swift */; }; @@ -38,7 +37,6 @@ A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; }; A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; }; - A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = ""; }; A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = ""; }; A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = ""; }; A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = ""; }; @@ -108,7 +106,6 @@ children = ( A201000000000000000010 /* Components.swift */, A201000000000000000011 /* Icons.swift */, - A201000000000000000012 /* PathMap.swift */, A201000000000000000013 /* Screens.swift */, A201000000000000000014 /* Exercises.swift */, A201000000000000000015 /* ExercisesMore.swift */, @@ -230,7 +227,6 @@ A101000000000000000009 /* Updater.swift in Sources */, A101000000000000000010 /* Components.swift in Sources */, A101000000000000000011 /* Icons.swift in Sources */, - A101000000000000000012 /* PathMap.swift in Sources */, A101000000000000000013 /* Screens.swift in Sources */, A101000000000000000014 /* Exercises.swift in Sources */, A101000000000000000015 /* ExercisesMore.swift in Sources */, diff --git a/ios/Maturita/AppModel.swift b/ios/Maturita/AppModel.swift index e010583..c8cdb04 100644 --- a/ios/Maturita/AppModel.swift +++ b/ios/Maturita/AppModel.swift @@ -19,6 +19,18 @@ final class AppModel: ObservableObject { themeId = progress.themeId mode = progress.mode lang = progress.lang + #if DEBUG + // `xcrun simctl launch 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) } diff --git a/ios/Maturita/Data/Theme.swift b/ios/Maturita/Data/Theme.swift index 2ff0a59..fb439aa 100644 --- a/ios/Maturita/Data/Theme.swift +++ b/ios/Maturita/Data/Theme.swift @@ -24,6 +24,8 @@ struct Palette { let success, success2, warning, error: Color let onAccent, node, lockedBg, lockedBorder, rail: Color let finishDark, finishLight, finishStroke: Color + /// Accent that stays readable as text and icon tint on the theme's background. + let tint: Color } private func hx(_ v: UInt32) -> Color { @@ -34,6 +36,29 @@ private func hx(_ v: UInt32) -> Color { ) } +private func luminance(_ v: UInt32) -> Double { + func channel(_ c: UInt32) -> Double { + let s = Double(c) / 255 + return s <= 0.03928 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4) + } + return 0.2126 * channel((v >> 16) & 0xFF) + 0.7152 * channel((v >> 8) & 0xFF) + 0.0722 * channel(v & 0xFF) +} + +private func contrast(_ a: UInt32, _ b: UInt32) -> Double { + let la = luminance(a), lb = luminance(b) + return (max(la, lb) + 0.05) / (min(la, lb) + 0.05) +} + +/// Pick the first accent with enough contrast against the background; yellow on +/// white is unreadable as tab labels and link text. +private func readableTint(_ c: [UInt32]) -> UInt32 { + let background = c[1] + for candidate in [c[9], c[10], c[11]] where contrast(candidate, background) >= 3.0 { + return candidate + } + return c[7] +} + private func pal(_ c: [UInt32]) -> Palette { Palette( crust: hx(c[0]), base: hx(c[1]), mantle: hx(c[2]), surface0: hx(c[3]), @@ -42,7 +67,8 @@ private func pal(_ c: [UInt32]) -> Palette { success: hx(c[12]), success2: hx(c[13]), warning: hx(c[14]), error: hx(c[15]), onAccent: hx(c[16]), node: hx(c[17]), lockedBg: hx(c[18]), lockedBorder: hx(c[19]), rail: hx(c[20]), finishDark: hx(c[21]), - finishLight: hx(c[22]), finishStroke: hx(c[23]) + finishLight: hx(c[22]), finishStroke: hx(c[23]), + tint: hx(readableTint(c)) ) } diff --git a/ios/Maturita/MaturitaApp.swift b/ios/Maturita/MaturitaApp.swift index a037523..abb4510 100644 --- a/ios/Maturita/MaturitaApp.swift +++ b/ios/Maturita/MaturitaApp.swift @@ -22,7 +22,7 @@ struct NativeRoot: View { tabs } } - .tint(vm.palette.accent) + .tint(vm.palette.tint) .preferredColorScheme(vm.mode == .dark ? .dark : .light) .onAppear { vm.checkUpdate(false) } } @@ -151,7 +151,7 @@ struct RouteDestination: View { case .mluvnice: LessonListScreen( title: vm.tr("Mluvnice"), - subtitle: "Cvičení z mluvnice", + subtitle: vm.tr("mluv_sub"), rows: vm.content.mluvnice.map { m in let n = m.int("id") return LessonRow( diff --git a/ios/Maturita/UI/Components.swift b/ios/Maturita/UI/Components.swift index 63af591..221f0da 100644 --- a/ios/Maturita/UI/Components.swift +++ b/ios/Maturita/UI/Components.swift @@ -44,9 +44,13 @@ struct PrimaryButton: View { var action: () -> Void var body: some View { - Button(label, action: action) - .buttonStyle(.borderedProminent) - .controlSize(.large) + Button(action: action) { + Text(label) + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .controlSize(.large) } } @@ -78,9 +82,13 @@ struct FeedbackLine: View { var body: some View { if !text.isEmpty { - Text(text) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error) + Label { + Text(text) + } icon: { + Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill") + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error) } } } @@ -107,21 +115,28 @@ struct WordField: View { var palette: Palette? var body: some View { - TextField(placeholder, text: $value) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .padding(12) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(border, lineWidth: mark == nil ? 0 : 1.5) - ) + HStack(spacing: 8) { + TextField(placeholder, text: $value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + if let mark { + Image(systemName: mark ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(mark ? (palette?.success ?? .green) : (palette?.error ?? .red)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(border, lineWidth: mark == nil ? 0.5 : 1.5) + ) } private var border: Color { if mark == true { return palette?.success ?? .green } if mark == false { return palette?.error ?? .red } - return .clear + return Color(.separator) } } @@ -158,10 +173,29 @@ struct WordChip: View { var body: some View { Button(text, action: action) .buttonStyle(.bordered) + .tint(.primary) .controlSize(.small) } } +/// Drop zone for word ordering: visible even when empty. +struct WordSlot: View { + let empty: Bool + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .padding(8) + .background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(style: StrokeStyle(lineWidth: empty ? 1 : 0, dash: [5, 4])) + .foregroundStyle(.tertiary) + ) + } +} + struct OptionChip: View { let text: String let selected: Bool @@ -171,15 +205,32 @@ struct OptionChip: View { var body: some View { Button(action: action) { HStack { - Text(text).frame(maxWidth: .infinity, alignment: .leading) - if selected { Image(systemName: "checkmark") } + Image(systemName: symbol) + .foregroundStyle(symbolColor) + Text(text) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) } + .padding(.vertical, 4) } .buttonStyle(.bordered) .tint(tint) .padding(.bottom, 4) } + private var symbol: String { + if mark == true { return "checkmark.circle.fill" } + if mark == false { return "xmark.circle.fill" } + return selected ? "largecircle.fill.circle" : "circle" + } + + private var symbolColor: Color { + if mark == true { return .green } + if mark == false { return .red } + return selected ? .accentColor : .secondary + } + private var tint: Color { if mark == true { return .green } if mark == false { return .red } @@ -196,6 +247,39 @@ extension View { self } } + + /// Detail screens with their own bottom action hide the tab bar so the + /// primary button is never covered by it. + func detailScreen(_ title: String, subtitle: String? = nil) -> some View { + navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .navSubtitle(subtitle) + .toolbar(.hidden, for: .tabBar) + } + + /// Full-width, leading-aligned content for exercise screens. + func exerciseContent() -> some View { + frame(maxWidth: .infinity, alignment: .leading) + .padding() + } +} + +/// Bottom action area used by exercises and lesson slides. +struct BottomAction: View { + let label: String + var action: () -> Void + @ViewBuilder var extra: () -> Extra + + var body: some View { + VStack(spacing: 10) { + extra() + PrimaryButton(label: label, action: action) + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 4) + .background(.bar) + } } func subjectSymbol(_ icon: String) -> String { @@ -207,3 +291,24 @@ func subjectSymbol(_ icon: String) -> String { default: return "lock.fill" } } + +/// Subject glyph for list rows: real flags for the languages, SF Symbols otherwise. +struct SubjectIcon: View { + let icon: String + var open = true + + var body: some View { + Group { + switch icon { + case "de": GermanFlag(size: 26) + case "cz": CzechFlag(size: 26) + default: + Image(systemName: subjectSymbol(icon)) + .font(.title3) + .foregroundStyle(open ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary)) + } + } + .frame(width: 28, height: 28) + .opacity(open ? 1 : 0.6) + } +} diff --git a/ios/Maturita/UI/Exercises.swift b/ios/Maturita/UI/Exercises.swift index 33d1344..0c68e1b 100644 --- a/ios/Maturita/UI/Exercises.swift +++ b/ios/Maturita/UI/Exercises.swift @@ -14,27 +14,19 @@ struct ExerciseScaffold: View { let p = vm.palette ScrollView { VStack(alignment: .leading, spacing: 8) { content() } - .padding() - } - .navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) - .navSubtitle(sub) - .toolbar { - ToolbarItem(placement: .bottomBar) { - Button(action, action: onAction) - .buttonStyle(.borderedProminent) - } + .exerciseContent() } + .scrollDismissesKeyboard(.interactively) + .detailScreen(title, subtitle: sub) .safeAreaInset(edge: .bottom) { - if !feedback.isEmpty { - FeedbackLine(text: feedback, kind: kind, palette: p) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .padding(.horizontal) - .padding(.bottom, 4) + BottomAction(label: action, action: onAction) { + if !feedback.isEmpty { + FeedbackLine(text: feedback, kind: kind, palette: p) + .frame(maxWidth: .infinity, alignment: .leading) + } } } + .animation(.default, value: feedback) } } @@ -102,7 +94,7 @@ struct DispatchExercise: View { case "letters", "letters_gap": LettersEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "table": TableEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) case "reveal": RevealEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone) - default: Text("?").foregroundStyle(vm.palette.text) + default: Text("?").foregroundStyle(.primary) } } } @@ -139,8 +131,7 @@ private struct ChoiceEx: View { }, feedback: fb.0, kind: fb.1) { ForEach(qs.indices, id: \.self) { i in Text("\(i + 1). \(qs[i].str("prompt"))") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(p.text) + .font(.body.weight(.semibold)) .padding(.bottom, 6) ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in let sel = picks[i] == o @@ -196,20 +187,18 @@ private struct AssemblyEx: View { }, feedback: fb.0, kind: fb.1) { ForEach(items.indices, id: \.self) { i in if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt) } - FlowLayout { - if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) } - ForEach(placed[i], id: \.self) { idx in - if idx < items[i].strs("words").count { - WordChip(text: items[i].strs("words")[idx]) { - placed[i].removeAll { $0 == idx } - pools[i].append(idx) + WordSlot(empty: placed[i].isEmpty) { + FlowLayout { + ForEach(placed[i], id: \.self) { idx in + if idx < items[i].strs("words").count { + WordChip(text: items[i].strs("words")[idx]) { + placed[i].removeAll { $0 == idx } + pools[i].append(idx) + } } } } } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .padding(.bottom, 6) FlowLayout(spacing: 6) { ForEach(pools[i], id: \.self) { idx in @@ -395,7 +384,7 @@ private struct AssignEx: View { SegChip(label: groups[i], selected: active == i) { active = i } } } - Text(vm.tr("wordbank")).font(.system(size: 12)).foregroundStyle(p.subtext).padding(.top, 10) + Text(vm.tr("wordbank")).font(.caption).foregroundStyle(.secondary).padding(.top, 10) FlowLayout(spacing: 6) { ForEach(items.indices, id: \.self) { i in if loc[i] == -1 { @@ -405,7 +394,7 @@ private struct AssignEx: View { } } ForEach(groups.indices, id: \.self) { g in - Text(groups[g]).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 10) + Text(groups[g]).font(.caption.weight(.semibold)).foregroundStyle(.secondary).padding(.top, 10) FlowLayout(spacing: 6) { ForEach(items.indices, id: \.self) { i in if loc[i] == g { @@ -451,7 +440,8 @@ private struct HangmanEx: View { let letters = spec.strs("letters") let p = vm.palette let cur = word < words.count ? words[word].uppercased() : "" - VStack(alignment: .leading, spacing: 8) { + ScrollView { + VStack(alignment: .leading, spacing: 8) { Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(.secondary) if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))") } Canvas { ctx, size in @@ -461,30 +451,30 @@ private struct HangmanEx: View { gallows.move(to: CGPoint(x: 70, y: size.height - 10)) gallows.addLine(to: CGPoint(x: 70, y: 16)) gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, y: 16)) - ctx.stroke(gallows, with: .color(p.text), style: StrokeStyle(lineWidth: 6, lineCap: .round)) + ctx.stroke(gallows, with: .color(.primary), style: StrokeStyle(lineWidth: 6, lineCap: .round)) let x = size.width * 0.3 + 40 if misses > 0 { var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36)) - ctx.stroke(rope, with: .color(p.text), lineWidth: 4) + ctx.stroke(rope, with: .color(.primary), lineWidth: 4) } if misses > 1 { - ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(p.text), lineWidth: 4) + ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(.primary), lineWidth: 4) } if misses > 2 { var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110)) - ctx.stroke(body, with: .color(p.text), lineWidth: 4) + ctx.stroke(body, with: .color(.primary), lineWidth: 4) } if misses > 3 { var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100)) - ctx.stroke(a, with: .color(p.text), lineWidth: 4) + ctx.stroke(a, with: .color(.primary), lineWidth: 4) } if misses > 4 { var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100)) - ctx.stroke(a, with: .color(p.text), lineWidth: 4) + ctx.stroke(a, with: .color(.primary), lineWidth: 4) } if misses > 5 { var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140)) - ctx.stroke(a, with: .color(p.text), lineWidth: 4) + ctx.stroke(a, with: .color(.primary), lineWidth: 4) } } .frame(height: 160) @@ -493,13 +483,14 @@ private struct HangmanEx: View { let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame } let show = idx.map { guessed[$0] } ?? false Text(show ? String(ch) : "_") - .font(.system(size: 26, weight: .bold)) - .foregroundStyle(p.text) + .font(.system(size: 26, weight: .bold, design: .rounded)) + .monospaced() } } - ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in - HStack(spacing: 4) { - ForEach(row, id: \.self) { letter in + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + FlowLayout(spacing: 6) { + ForEach(letters, id: \.self) { letter in if let i = letters.firstIndex(of: letter) { Button { guessed[i] = true @@ -523,17 +514,14 @@ private struct HangmanEx: View { } } label: { Text(letter) - .font(.system(size: 15, weight: .bold)) - .foregroundStyle(p.text) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(guessed[i] ? p.surface1 : p.mantle, in: RoundedRectangle(cornerRadius: 8)) + .font(.body.weight(.semibold)) + .frame(minWidth: 22) } - .buttonStyle(.plain) + .buttonStyle(.bordered) + .tint(guessed[i] ? .secondary : .primary) .disabled(guessed[i] || finished) } } - } } FeedbackLine(text: fb.0, kind: fb.1, palette: p) if misses >= 6 && !finished { @@ -543,12 +531,10 @@ private struct HangmanEx: View { fb = ("", "") } } - Spacer() } - .padding() - .navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) - .navSubtitle(sub) + .exerciseContent() + } + .detailScreen(title, subtitle: sub) } } @@ -586,40 +572,63 @@ private struct VocabEx: View { var body: some View { let p = vm.palette - VStack(alignment: .leading, spacing: 8) { - Text(vm.fmt("trans_progress", done, total)).foregroundStyle(.secondary) + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ProgressView(value: Double(done), total: Double(max(total, 1))) + Text(vm.fmt("trans_progress", done, total)).font(.footnote).foregroundStyle(.secondary) if cards.isEmpty { - Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success) + Label(vm.tr("trans_done"), systemImage: "checkmark.seal.fill") + .font(.headline) + .foregroundStyle(p.success) + .padding(.top, 8) } else if index < cards.count { let cur = cards[index] - Hint(text: cur.0) - Prompt(text: cur.1) - if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) } - WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p) - PrimaryButton(label: vm.tr("check")) { - let norm = normalizeAnswer(value) - if answerAccepts(norm, cur.2) { - done += 1 - cards.remove(at: index) - if cards.isEmpty { fb = (vm.tr("trans_done"), "ok"); onDone() } - else { if index >= cards.count { index = 0 }; value = ""; fb = ("", "") } - } else if answerIncomplete(norm, cur.2) { - fb = (vm.tr("trans_incomplete"), "warn") - } else { - fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err") - let item = cards.remove(at: index) - cards.append(item) - if index >= cards.count { index = 0 } - value = "" + GlassCard { + VStack(alignment: .leading, spacing: 6) { + Text(cur.0).font(.caption.weight(.semibold)).foregroundStyle(.secondary) + Text(cur.1).font(.title2.weight(.semibold)) } } + .padding(.vertical, 8) + if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) } + WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p) + .submitLabel(.done) + .onSubmit { check(cur) } FeedbackLine(text: fb.0, kind: fb.1, palette: p) } - Spacer() } - .padding() - .navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) - .navSubtitle(sub) + .exerciseContent() + } + .scrollDismissesKeyboard(.interactively) + .detailScreen(title, subtitle: sub) + .safeAreaInset(edge: .bottom) { + if index < cards.count { + BottomAction(label: vm.tr("check"), action: { check(cards[index]) }) { EmptyView() } + } + } + } + + private func check(_ cur: (String, String, String)) { + let norm = normalizeAnswer(value) + if answerAccepts(norm, cur.2) { + done += 1 + cards.remove(at: index) + if cards.isEmpty { + fb = (vm.tr("trans_done"), "ok") + onDone() + } else { + if index >= cards.count { index = 0 } + value = "" + fb = ("", "") + } + } else if answerIncomplete(norm, cur.2) { + fb = (vm.tr("trans_incomplete"), "warn") + } else { + fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err") + let item = cards.remove(at: index) + cards.append(item) + if index >= cards.count { index = 0 } + value = "" + } } } diff --git a/ios/Maturita/UI/ExercisesMore.swift b/ios/Maturita/UI/ExercisesMore.swift index 873e717..752dac6 100644 --- a/ios/Maturita/UI/ExercisesMore.swift +++ b/ios/Maturita/UI/ExercisesMore.swift @@ -16,8 +16,8 @@ struct DialogEx: View { let line = lines[i] if let name = line.name { Text(name) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(vm.palette.subtext) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) .padding(.vertical, 6) } ComboLine( @@ -68,7 +68,7 @@ struct ComboEx: View { ComboRows(vm: vm, title: title, sub: sub, pool: spec.strs("pool"), n: allRows.count, onDone: onDone, revealMean: spec.bool("transUpfront")) { picks, expected, revealed in ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in if let title = line.title { - Text(title).font(.system(size: 15, weight: .semibold)).foregroundStyle(vm.palette.subtext) + Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(.secondary) } ComboLine( vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx, @@ -92,18 +92,18 @@ struct ComboEx: View { ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in FlowLayout(spacing: 4) { if let num = block.num { - Text(num).font(.system(size: 16, weight: .semibold)).foregroundStyle(vm.palette.text) + Text(num).font(.body.weight(.semibold)).foregroundStyle(.primary) } ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in switch part { case .text(let s): - Text(s).foregroundStyle(vm.palette.text) + Text(s).foregroundStyle(.primary) case .blank(let idx, let ans): ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true) } } if let mean = block.meaning { - Text(vm.tr(mean)).font(.system(size: 12)).foregroundStyle(vm.palette.subtext) + Text(vm.tr(mean)).font(.caption).foregroundStyle(.secondary) } } .padding(.bottom, 6) @@ -267,35 +267,36 @@ private struct ComboLine: View { let chip = Button { open = true } label: { - Text(pick.isEmpty ? "—" : pick) - .font(.system(size: 15)) - .foregroundStyle(pick.isEmpty ? p.overlay : p.text) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(p.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(mark == true ? p.success : mark == false ? p.error : p.text.opacity(0.12), lineWidth: 1) - ) + HStack(spacing: 4) { + Text(pick.isEmpty ? "…" : pick) + .foregroundStyle(pick.isEmpty ? Color.secondary : Color.primary) + .frame(minWidth: 36) + Image(systemName: mark == true ? "checkmark.circle.fill" : mark == false ? "xmark.circle.fill" : "chevron.up.chevron.down") + .font(.caption2) + .foregroundStyle(mark == true ? p.success : mark == false ? p.error : Color.secondary) + } } - .buttonStyle(.plain) + .buttonStyle(.bordered) + .controlSize(.small) + .tint(mark == true ? p.success : mark == false ? p.error : .secondary) Group { if inline { chip } else { FlowLayout(spacing: 6) { - if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) } + if !prefix.isEmpty { Text(prefix) } chip - if !suffix.isEmpty { Text(suffix).font(.system(size: 16)).foregroundStyle(p.text) } + if !suffix.isEmpty { Text(suffix) } } + .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) } } .onAppear { if idx >= 0 && idx < expected.count { expected[idx] = answer } } - .onChange(of: answer) { new in + .onChange(of: answer) { _, new in if idx >= 0 && idx < expected.count { expected[idx] = new } } .sheet(isPresented: $open) { @@ -319,16 +320,22 @@ struct WordPicker: View { var body: some View { NavigationStack { List(Array(pool.enumerated()), id: \.offset) { _, w in - Button(w) { onPick(w) } + Button { + onPick(w) + } label: { + Text(w).foregroundStyle(.primary) + } } - .navigationTitle("") + .navigationTitle("Vyberte slovo") + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Zavřít", role: .cancel) { onPick(nil) } + Button(role: .cancel) { onPick(nil) } label: { Image(systemName: "xmark") } } } } .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } } @@ -351,12 +358,12 @@ struct Ex2Ex: View { ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in FlowLayout(spacing: 4) { if let num = row.num { - Text(num).foregroundStyle(vm.palette.text).padding(.trailing, 6) + Text(num).foregroundStyle(.primary).padding(.trailing, 6) } ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in switch part { case .text(let s): - Text(s).foregroundStyle(vm.palette.text) + Text(s).foregroundStyle(.primary) case .blank(let idx, let ans): ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true) } @@ -415,7 +422,6 @@ struct LettersEx: View { var body: some View { let rows = spec.arr("rows") - let p = vm.palette ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: { var i = 0, ok = 0, total = 0 for r in rows { @@ -449,12 +455,12 @@ private struct LettersRows: View { VStack(alignment: .leading, spacing: 8) { ForEach(pairs.indices, id: \.self) { ri in let row = pairs[ri] - if let num = row.num { Text(num).foregroundStyle(p.subtext) } + if let num = row.num { Text(num).foregroundStyle(.secondary) } FlowLayout { ForEach(row.parts.indices, id: \.self) { pi in switch row.parts[pi] { case .text(let s): - Text(s).foregroundStyle(p.text) + Text(s).foregroundStyle(.primary) case .field(let idx, let ans): WordField( value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }), @@ -539,10 +545,10 @@ struct TableEx: View { revealed = true }, feedback: fb.0, kind: fb.1) { ForEach(nouns.indices, id: \.self) { r in - Text(nouns[r]).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) + Text(nouns[r]).font(.body.weight(.bold)).foregroundStyle(.primary) ForEach(persons.indices, id: \.self) { c in let i = r * persons.count + c - Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext) + Text(persons[c]).font(.caption).foregroundStyle(.secondary) WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), placeholder: "", palette: p) } Spacer().frame(height: 8) @@ -658,29 +664,41 @@ private struct VlsmEx: View { let tasks = vm.content.netTasks let answers = vm.content.netAnswers let p = vm.palette - VStack(alignment: .leading, spacing: 0) { - ScrollView { + ScrollView { VStack(alignment: .leading, spacing: 12) { ForEach(tasks.indices, id: \.self) { ti in let task = tasks[ti] let block = ti < answers.count ? answers[ti] : [] let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 } - Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8) + GlassCard { + Label { + Text(task.str("prompt")) + } icon: { + Image(systemName: done[ti] ? "checkmark.circle.fill" : "\(ti + 1).circle") + .foregroundStyle(done[ti] ? p.success : .secondary) + } + } + .padding(.vertical, 4) if infeasible { Hint(text: vm.tr("net_infeasible")) } else { ForEach(block.indices, id: \.self) { si in Text("Podsíť \(String(UnicodeScalar(65 + si)!))") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(p.subtext) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.top, 6) PrefixMenu(value: Binding( get: { rows[ti][si].pfx }, set: { rows[ti][si].pfx = $0 } ), palette: p) WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), placeholder: "síť", palette: p) + .keyboardType(.decimalPad) WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), placeholder: "broadcast", palette: p) + .keyboardType(.decimalPad) WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), placeholder: "první uzel", palette: p) + .keyboardType(.decimalPad) WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), placeholder: "poslední uzel", palette: p) + .keyboardType(.decimalPad) } PillButton(label: vm.tr("check")) { let ok = block.indices.allSatisfy { si in @@ -717,14 +735,22 @@ private struct VlsmEx: View { maybeFinish() } if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) } + Divider().padding(.vertical, 8) } } - } - FeedbackLine(text: fb.0, kind: fb.1, palette: p) + .exerciseContent() } - .padding() - .navigationTitle(vm.tr("net_ex_title")) - .navigationBarTitleDisplayMode(.inline) + .scrollDismissesKeyboard(.interactively) + .detailScreen(vm.tr("net_ex_title")) + .safeAreaInset(edge: .bottom) { + if !fb.0.isEmpty { + FeedbackLine(text: fb.0, kind: fb.1, palette: p) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.bar) + } + } + .animation(.default, value: fb.0) } private func maybeFinish() { @@ -739,15 +765,17 @@ private struct PrefixMenu: View { var body: some View { Button { open = true } label: { - Text(value.isEmpty ? "/…" : value) - .font(.system(size: 15)) - .foregroundStyle(palette.text) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(palette.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + HStack { + Text("Prefix").foregroundStyle(.secondary) + Spacer() + Text(value.isEmpty ? "/…" : value) + .foregroundStyle(value.isEmpty ? Color.secondary : Color.primary) + .monospacedDigit() + Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary) + } } - .buttonStyle(.plain) - .padding(.vertical, 4) + .buttonStyle(.bordered) + .tint(.secondary) .sheet(isPresented: $open) { WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in if let word { value = word } @@ -765,63 +793,75 @@ struct LitQuizScreen: View { @State private var score = 0 @State private var answered = false @State private var finished = false + @State private var picked = -1 var body: some View { if let book = vm.content.book(id) { let qs = book.arr("quiz") let p = vm.palette - let kahoot = [p.accent, p.error, p.warning, p.success] - VStack(alignment: .leading, spacing: 8) { - if finished { - Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text) - Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext) - Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) - PrimaryButton(label: vm.tr("lit_again")) { - idx = 0; score = 0; answered = false; finished = false - } - } else if idx < qs.count { - let q = qs[idx] - HStack { - Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)).foregroundStyle(p.subtext) - Spacer() - Text(vm.fmt("lit_score_fmt", score, qs.count)).foregroundStyle(p.subtext) - } - Text(q.str("prompt")).font(.system(size: 18, weight: .semibold)).foregroundStyle(p.text).padding(.vertical, 12) - ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in - Button { - answered = true - if i == q.int("correct") { score += 1 } - } label: { - Text(opt) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(p.onAccent) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) - .background(kahoot[i % 4], in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - } - .buttonStyle(.plain) - .disabled(answered) - .padding(.bottom, 8) - } - if answered { - Meaning(text: q.str("expl"), palette: p, visible: true) - PrimaryButton(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next")) { - if idx + 1 >= qs.count { - finished = true - vm.markBookQuiz(id) - } else { - idx += 1 - answered = false + ScrollView { + VStack(alignment: .leading, spacing: 8) { + if finished { + GlassCard { + VStack(alignment: .leading, spacing: 8) { + Label(vm.tr("lit_finished"), systemImage: "rosette") + .font(.title2.weight(.bold)) + Text(vm.tr("lit_finished_text")).foregroundStyle(.secondary) + Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.headline) } } + } else if idx < qs.count { + let q = qs[idx] + ProgressView(value: Double(idx), total: Double(qs.count)) + HStack { + Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)) + Spacer() + Text(vm.fmt("lit_score_fmt", score, qs.count)) + } + .font(.footnote) + .foregroundStyle(.secondary) + Text(q.str("prompt")).font(.title3.weight(.semibold)).padding(.vertical, 12) + ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in + let correct = i == q.int("correct") + let chosen = answered && picked == i + OptionChip( + text: opt, + selected: chosen, + mark: answered ? (correct ? true : (chosen ? false : nil)) : nil + ) { + guard !answered else { return } + picked = i + answered = true + if correct { score += 1 } + } + } + if answered { + Meaning(text: q.str("expl"), palette: p, visible: true) + } } } - Spacer() + .exerciseContent() } - .padding() - .navigationTitle(vm.tr(book.str("quizTitle"))) - .navigationBarTitleDisplayMode(.inline) - .navigationSubtitle(vm.tr(book.str("quizSub"))) + .detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub"))) + .safeAreaInset(edge: .bottom) { + if finished { + BottomAction(label: vm.tr("lit_again"), action: { + idx = 0; score = 0; answered = false; finished = false; picked = -1 + }) { EmptyView() } + } else if answered { + BottomAction(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), action: { + if idx + 1 >= qs.count { + finished = true + vm.markBookQuiz(id) + } else { + idx += 1 + answered = false + picked = -1 + } + }) { EmptyView() } + } + } + .animation(.default, value: answered) } } } diff --git a/ios/Maturita/UI/Icons.swift b/ios/Maturita/UI/Icons.swift index 9874349..e45e54e 100644 --- a/ios/Maturita/UI/Icons.swift +++ b/ios/Maturita/UI/Icons.swift @@ -1,166 +1,6 @@ import SwiftUI -struct BackIcon: View { - var color: Color - var size: CGFloat = 16 - var body: some View { - Canvas { ctx, sz in - var p = Path() - p.move(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.18)) - p.addLine(to: CGPoint(x: sz.width * 0.28, y: sz.height * 0.5)) - p.addLine(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.82)) - ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round)) - } - .frame(width: size, height: size) - } -} - -struct SearchIcon: View { - var color: Color - var size: CGFloat = 18 - var body: some View { - Canvas { ctx, sz in - let r = min(sz.width, sz.height) * 0.28 - let c = CGPoint(x: sz.width * 0.42, y: sz.height * 0.42) - ctx.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.12) - var handle = Path() - handle.move(to: CGPoint(x: sz.width * 0.62, y: sz.height * 0.62)) - handle.addLine(to: CGPoint(x: sz.width * 0.82, y: sz.height * 0.82)) - ctx.stroke(handle, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.12, lineCap: .round)) - } - .frame(width: size, height: size) - } -} - -struct StatsIcon: View { - var color: Color - var size: CGFloat = 18 - var body: some View { - Canvas { ctx, sz in - let w = min(sz.width, sz.height) * 0.16 - let bars: [(CGFloat, CGFloat, CGFloat)] = [ - (0.18, 0.48, 0.36), (0.42, 0.22, 0.62), (0.66, 0.36, 0.48), - ] - for b in bars { - let rect = CGRect(x: sz.width * b.0, y: sz.height * b.1, width: w, height: sz.height * b.2) - ctx.fill(Path(roundedRect: rect, cornerRadius: w), with: .color(color)) - } - } - .frame(width: size, height: size) - } -} - -struct SettingsIcon: View { - var color: Color - var size: CGFloat = 18 - var body: some View { - Canvas { ctx, sz in - let cx = sz.width / 2 - let cy = sz.height / 2 - let s = min(sz.width, sz.height) - let rOuter = s * 0.48 - let rInner = s * 0.34 - let rHole = s * 0.16 - let teeth = 8 - let toothHalf = CGFloat.pi / CGFloat(teeth) - var gear = Path() - for i in 0.. 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) - } -} diff --git a/ios/Maturita/UI/PathMap.swift b/ios/Maturita/UI/PathMap.swift deleted file mode 100644 index 54a1139..0000000 --- a/ios/Maturita/UI/PathMap.swift +++ /dev/null @@ -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? -} diff --git a/ios/Maturita/UI/Screens.swift b/ios/Maturita/UI/Screens.swift index 7050cf0..a0424e9 100644 --- a/ios/Maturita/UI/Screens.swift +++ b/ios/Maturita/UI/Screens.swift @@ -54,7 +54,7 @@ struct PracticeHome: View { } } } icon: { - Image(systemName: subjectSymbol(s.str("icon"))) + SubjectIcon(icon: s.str("icon"), open: s.bool("open")) } } } @@ -77,11 +77,11 @@ struct SettingsScreen: View { vm.setTheme(id) } label: { HStack { - Circle().fill(themePalette(id, vm.mode).accent).frame(width: 14, height: 14) - Text(themeNames[id.rawValue]) + Circle().fill(themePalette(id, vm.mode).tint).frame(width: 14, height: 14) + Text(themeNames[id.rawValue]).foregroundStyle(.primary) Spacer() if vm.themeId == id { - Image(systemName: "checkmark") + Image(systemName: "checkmark").fontWeight(.semibold) } } } @@ -130,28 +130,42 @@ struct StatsScreen: View { let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx) List { Section { - HStack { - stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx)) - stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100))) - stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits)) + GlassCard { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx)) + stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100))) + stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits)) + } + ProgressView(value: pct) + } } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .listRowBackground(Color.clear) - ProgressView(value: pct) + .listRowSeparator(.hidden) } Section(vm.tr("stats_section")) { ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in let open = s.bool("open") let part = subjectSum(vm, s) - VStack(alignment: .leading, spacing: 6) { - Text(vm.tr(s.str("key"))) - Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked")) - .font(.caption) - .foregroundStyle(.secondary) - if open && part.totalEx > 0 { - ProgressView(value: Double(part.doneEx) / Double(part.totalEx)) + Label { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(vm.tr(s.str("key"))) + Spacer() + Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked")) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + if open && part.totalEx > 0 { + ProgressView(value: Double(part.doneEx) / Double(part.totalEx)) + } } + } icon: { + SubjectIcon(icon: s.str("icon"), open: open) } - .opacity(open ? 1 : 0.5) + .foregroundStyle(open ? .primary : .secondary) } } } @@ -159,9 +173,9 @@ struct StatsScreen: View { } private func stat(_ label: String, _ value: String) -> some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 2) { Text(label).font(.caption).foregroundStyle(.secondary) - Text(value).font(.title3.bold()) + Text(value).font(.title3.bold()).monospacedDigit() } .frame(maxWidth: .infinity, alignment: .leading) } @@ -397,29 +411,59 @@ struct SlidesScreen: View { .foregroundStyle(.secondary) Text(slide.str("title")).font(.title2.bold()) if let tip = slide.strOrNull("tip") { - GlassCard { Text(tip) } + GlassCard { + Label(tip, systemImage: "lightbulb.fill") + } } ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in Text(line).padding(.vertical, 4) } } .padding() + .padding(.bottom, 24) .frame(maxWidth: .infinity, alignment: .leading) } .tag(i) } } - .tabViewStyle(.page(indexDisplayMode: .automatic)) - .navigationTitle(title) - .navigationSubtitle(subtitle) - .toolbar { - ToolbarItem(placement: .bottomBar) { - if idx < slides.count - 1 { - Button(vm.tr("net_slide_next")) { idx += 1 } - } else { - NavigationLink(vm.tr("net_slide_start"), value: next) + .tabViewStyle(.page(indexDisplayMode: .never)) + .detailScreen(title, subtitle: subtitle) + .safeAreaInset(edge: .bottom) { + VStack(spacing: 10) { + HStack(spacing: 6) { + ForEach(slides.indices, id: \.self) { i in + Capsule() + .fill(i == idx ? Color.accentColor : Color(.tertiarySystemFill)) + .frame(width: i == idx ? 18 : 6, height: 6) + } + } + .animation(.default, value: idx) + HStack(spacing: 10) { + if idx > 0 { + Button { + withAnimation { idx -= 1 } + } label: { + Image(systemName: "chevron.left") + .frame(minWidth: 24) + } + .buttonStyle(.glass) + .controlSize(.large) + } + if idx < slides.count - 1 { + PrimaryButton(label: vm.tr("net_slide_next")) { + withAnimation { idx += 1 } + } + } else { + PrimaryButton(label: vm.tr("net_slide_start")) { + vm.go(next) + } + } } } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 4) + .background(.bar) } } } diff --git a/src/i18n.c b/src/i18n.c index 9851250..2a8e806 100644 --- a/src/i18n.c +++ b/src/i18n.c @@ -225,6 +225,8 @@ const TrEntry tr_ui[] = { {"stats", "Statistiky", "Statistics"}, {"stats_title", "Statistiky", "Statistics"}, {"stats_sub", "Přehled vašeho pokroku.", "Overview of your progress."}, + {"mluv_sub", "Cvičení z mluvnice – styl maturita / přijímačky z ČJL", + "Grammar exercises in the style of the maturita and entrance exams"}, {"stats_ex_label", "Cvičení", "Exercises"}, {"stats_pct_label", "Hotovo", "Done"}, {"stats_units_label", "Jednotky", "Units"}, diff --git a/src/mluvnice.c b/src/mluvnice.c index b69b5b5..d424adc 100644 --- a/src/mluvnice.c +++ b/src/mluvnice.c @@ -61,8 +61,7 @@ void mluvnice_init(void) { mluv_unit.title = "Mluvnice"; mluv_unit.page = "mluvnice"; mluv_unit.ex_tag = "mluv"; - mluv_unit.sub_key = "Cvičení z mluvnice – styl maturita / " - "přijímačky z ČJL"; + mluv_unit.sub_key = "mluv_sub"; mluv_unit.back_target = "czechmap"; mluv_unit.progress_file = app_progress_mluvnice; mluv_unit.n_ex = MLUV_N_EX; diff --git a/src/net.c b/src/net.c index 50dcb18..2ce0551 100644 --- a/src/net.c +++ b/src/net.c @@ -2669,7 +2669,10 @@ GtkWidget *net_qz_make_entry(void) { GtkWidget *e = gtk_entry_new(); gtk_widget_set_hexpand(e, TRUE); - gtk_editable_set_width_chars(GTK_EDITABLE(e), 15); + /* Four address columns must share the window; 15 chars each pushed the + * last column past the right edge. */ + gtk_editable_set_width_chars(GTK_EDITABLE(e), 7); + gtk_editable_set_max_width_chars(GTK_EDITABLE(e), 16); return e; } @@ -2688,8 +2691,10 @@ GtkWidget *build_net_exercise_page(void) { top_bar("netunit1", "net_ex_title", NULL)); scroll = gtk_scrolled_window_new(); + /* Never scroll sideways: the address table must shrink to the window + * instead of pushing its last column off screen. */ gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), - GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_widget_set_vexpand(scroll, TRUE); gtk_widget_set_margin_top(scroll, 12); gtk_box_append(GTK_BOX(page), scroll); @@ -2732,6 +2737,7 @@ GtkWidget *build_net_exercise_page(void) { gtk_grid_set_row_spacing(GTK_GRID(table), 6); gtk_grid_set_column_spacing(GTK_GRID(table), 8); gtk_widget_set_margin_top(table, 6); + gtk_widget_set_hexpand(table, TRUE); gtk_box_append(GTK_BOX(task), table); for (guint c = 1; c < 6; c++) { diff --git a/src/stats.c b/src/stats.c index 1904dbe..648756b 100644 --- a/src/stats.c +++ b/src/stats.c @@ -299,9 +299,11 @@ GtkWidget *build_stats_page(void) { outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_widget_add_css_class(outer, "stats-page"); + /* Same header rhythm as the other pages (top_bar). */ top = gtk_center_box_new(); - gtk_widget_set_margin_top(top, 2); - gtk_widget_set_margin_bottom(top, 6); + gtk_widget_add_css_class(top, "page-top"); + gtk_widget_set_margin_top(top, 8); + gtk_widget_set_margin_bottom(top, 18); gtk_box_append(GTK_BOX(outer), top); back = make_back_button("subjects");