mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Rework the mobile UIs on native components and fix layout bugs everywhere.
Android moves to Material 3 (navigation bar, app bars, list items, bottom sheets, pager) with a real back stack. iOS hides the tab bar in exercises so the bottom action is reachable, aligns content, and uses system colours so themes and dark mode work. Both pick a readable accent tint. Desktop keeps the VLSM table inside the window and gives statistics the same header rhythm as other pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a1abac51f5
commit
0621311b4c
25 changed files with 1965 additions and 1836 deletions
|
|
@ -26,7 +26,6 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||
private set
|
||||
var lang by mutableStateOf(progress.lang)
|
||||
private set
|
||||
var settingsOpen by mutableStateOf(false)
|
||||
var searchOpen by mutableStateOf(false)
|
||||
var searchQuery by mutableStateOf("")
|
||||
var tick by mutableStateOf(0)
|
||||
|
|
@ -34,19 +33,32 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||
var update by mutableStateOf(UpdateState())
|
||||
private set
|
||||
|
||||
private val stack = mutableStateListOf<Route>(Route.Welcome)
|
||||
private val stack = mutableStateListOf<Route>(Route.Subjects)
|
||||
val route: Route get() = stack.last()
|
||||
val canGoBack: Boolean get() = stack.size > 1
|
||||
val root: Route get() = stack.first()
|
||||
val canGoBack: Boolean get() = stack.size > 1 || root != Route.Subjects
|
||||
|
||||
val palette get() = themePalette(themeId, mode)
|
||||
|
||||
fun tr(key: String?) = content.tr(key, lang)
|
||||
fun fmt(key: String, vararg args: Any) = content.fmt(key, lang, *args)
|
||||
|
||||
/** Roots switch the bottom tab; everything else is pushed on the practice stack. */
|
||||
fun go(r: Route) {
|
||||
if (stack.last() == r) return
|
||||
stack.add(r)
|
||||
searchOpen = false
|
||||
if (stack.last() == r) return
|
||||
if (r.isRoot) {
|
||||
val target = if (r == Route.Welcome) Route.Subjects else r
|
||||
stack.clear()
|
||||
stack.add(target)
|
||||
return
|
||||
}
|
||||
if (root != Route.Subjects) {
|
||||
stack.clear()
|
||||
stack.addAll(r.stack())
|
||||
return
|
||||
}
|
||||
stack.add(r)
|
||||
}
|
||||
|
||||
fun goPage(page: String?) {
|
||||
|
|
@ -54,8 +66,25 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||
go(r)
|
||||
}
|
||||
|
||||
fun back() {
|
||||
/** Jumping from search rebuilds the whole path so back walks up level by level. */
|
||||
fun openPage(page: String) {
|
||||
val r = routeFromPage(page) ?: return
|
||||
searchOpen = false
|
||||
stack.clear()
|
||||
stack.addAll(r.stack())
|
||||
}
|
||||
|
||||
/** Replace the current screen (lesson slides -> its exercise). */
|
||||
fun replace(r: Route) {
|
||||
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||
stack.add(r)
|
||||
}
|
||||
|
||||
fun back() {
|
||||
when {
|
||||
stack.size > 1 -> stack.removeAt(stack.lastIndex)
|
||||
root != Route.Subjects -> go(Route.Subjects)
|
||||
}
|
||||
}
|
||||
|
||||
fun setTheme(id: ThemeId) {
|
||||
|
|
|
|||
|
|
@ -12,56 +12,39 @@ import androidx.activity.compose.BackHandler
|
|||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.maturita.maturita.data.ColorMode
|
||||
import org.maturita.maturita.data.Palette
|
||||
import org.maturita.maturita.ui.BookListScreen
|
||||
import org.maturita.maturita.ui.BookScreen
|
||||
import org.maturita.maturita.ui.CzechMapScreen
|
||||
import org.maturita.maturita.ui.GermanExercise
|
||||
import org.maturita.maturita.ui.HomeScreen
|
||||
import org.maturita.maturita.ui.HwQuizScreen
|
||||
import org.maturita.maturita.ui.IconHit
|
||||
import org.maturita.maturita.ui.LessonMapScreen
|
||||
import org.maturita.maturita.ui.LessonListScreen
|
||||
import org.maturita.maturita.ui.LessonRow
|
||||
import org.maturita.maturita.ui.LitQuizScreen
|
||||
import org.maturita.maturita.ui.MapNode
|
||||
import org.maturita.maturita.ui.MluvExercise
|
||||
import org.maturita.maturita.ui.NetQuizScreen
|
||||
import org.maturita.maturita.ui.NetYearsScreen
|
||||
import org.maturita.maturita.ui.PlotScreen
|
||||
import org.maturita.maturita.ui.RoadmapScreen
|
||||
import org.maturita.maturita.ui.SearchIcon
|
||||
import org.maturita.maturita.ui.SearchOverlay
|
||||
import org.maturita.maturita.ui.SettingsIcon
|
||||
import org.maturita.maturita.ui.SettingsSheet
|
||||
import org.maturita.maturita.ui.SearchScreen
|
||||
import org.maturita.maturita.ui.SettingsScreen
|
||||
import org.maturita.maturita.ui.SlidesScreen
|
||||
import org.maturita.maturita.ui.appTextStyle
|
||||
import org.maturita.maturita.ui.appTypography
|
||||
import org.maturita.maturita.ui.StatsIcon
|
||||
import org.maturita.maturita.ui.StatsScreen
|
||||
import org.maturita.maturita.ui.SubjectsScreen
|
||||
import org.maturita.maturita.ui.UnitMapScreen
|
||||
import org.maturita.maturita.data.ColorMode
|
||||
import org.maturita.maturita.ui.VocabExercise
|
||||
import org.maturita.maturita.ui.WelcomeScreen
|
||||
import org.maturita.maturita.ui.appTypography
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val vm: AppViewModel by viewModels()
|
||||
|
|
@ -125,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ sealed class Route(val page: String) {
|
|||
data object Welcome : Route("welcome")
|
||||
data object Subjects : Route("subjects")
|
||||
data object Stats : Route("stats")
|
||||
data object Settings : Route("settings")
|
||||
data object Roadmap : Route("roadmap")
|
||||
data class UnitMap(val unitId: Int) : Route("unit${unitId + 1}")
|
||||
data class GermanEx(val unitId: Int, val ex: Int) : Route("u${unitId + 1}e$ex")
|
||||
|
|
@ -22,12 +23,46 @@ sealed class Route(val page: String) {
|
|||
data class Book(val id: String) : Route(if (id == "1984") "cetba1984" else "cetbaFuks")
|
||||
data class BookQuiz(val id: String) : Route(if (id == "1984") "cetba1984quiz" else "cetbaFuksQuiz")
|
||||
data class BookPlot(val id: String) : Route(if (id == "1984") "cetba1984dej" else "cetbaFuksDej")
|
||||
|
||||
/** Bottom-navigation roots; everything else is pushed on top of [Subjects]. */
|
||||
val isRoot: Boolean get() = this is Welcome || this is Subjects || this is Stats || this is Settings
|
||||
|
||||
/** Screens with their own bottom action bar; the navigation bar stays hidden there. */
|
||||
val isExercise: Boolean
|
||||
get() = this is GermanEx || this is Vocab || this is NetEx || this is HwEx ||
|
||||
this is MluvEx || this is BookQuiz || this is BookPlot || this is NetLesson || this is HwLesson
|
||||
|
||||
/** Full stack from the practice root to this screen, so back always goes up one level. */
|
||||
fun stack(): List<Route> = when (this) {
|
||||
Welcome, Subjects -> listOf(Subjects)
|
||||
Stats -> listOf(Stats)
|
||||
Settings -> listOf(Settings)
|
||||
Roadmap -> listOf(Subjects, Roadmap)
|
||||
is UnitMap -> listOf(Subjects, Roadmap, this)
|
||||
is GermanEx -> listOf(Subjects, Roadmap, UnitMap(unitId), this)
|
||||
is Vocab -> listOf(Subjects, Roadmap, UnitMap(unitId), this)
|
||||
NetYears -> listOf(Subjects, NetYears)
|
||||
NetMap -> listOf(Subjects, NetYears, NetMap)
|
||||
is NetLesson -> listOf(Subjects, NetYears, NetMap, this)
|
||||
is NetEx -> listOf(Subjects, NetYears, NetMap, NetLesson(id), this)
|
||||
HwMap -> listOf(Subjects, HwMap)
|
||||
is HwLesson -> listOf(Subjects, HwMap, this)
|
||||
is HwEx -> listOf(Subjects, HwMap, HwLesson(id), this)
|
||||
CzechMap -> listOf(Subjects, CzechMap)
|
||||
Mluvnice -> listOf(Subjects, CzechMap, Mluvnice)
|
||||
is MluvEx -> listOf(Subjects, CzechMap, Mluvnice, this)
|
||||
ReadingList -> listOf(Subjects, CzechMap, ReadingList)
|
||||
is Book -> listOf(Subjects, CzechMap, ReadingList, this)
|
||||
is BookQuiz -> listOf(Subjects, CzechMap, ReadingList, Book(id), this)
|
||||
is BookPlot -> listOf(Subjects, CzechMap, ReadingList, Book(id), this)
|
||||
}
|
||||
}
|
||||
|
||||
fun routeFromPage(page: String): Route? = when (page) {
|
||||
"welcome" -> Route.Welcome
|
||||
"subjects" -> Route.Subjects
|
||||
"stats" -> Route.Stats
|
||||
"settings" -> Route.Settings
|
||||
"roadmap" -> Route.Roadmap
|
||||
"netyears" -> Route.NetYears
|
||||
"netmap" -> Route.NetMap
|
||||
|
|
|
|||
|
|
@ -36,16 +36,48 @@ data class Palette(
|
|||
val finishDark: Color,
|
||||
val finishLight: Color,
|
||||
val finishStroke: Color,
|
||||
/** Accent that stays readable as text and icon tint on [base]. */
|
||||
val tint: Color,
|
||||
/** Text colour that reads on [tint]. */
|
||||
val onTint: Color,
|
||||
)
|
||||
|
||||
private fun hx(v: Long) = Color(0xFF000000L or v)
|
||||
|
||||
private fun pal(vararg c: Long) = Palette(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,38 +1,77 @@
|
|||
package org.maturita.maturita.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontSynthesis
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.maturita.maturita.AppViewModel
|
||||
import org.maturita.maturita.data.Palette
|
||||
import org.maturita.maturita.Route
|
||||
|
||||
/** Bold is capped at Medium: synthetic bold doubled glyphs on some devices. */
|
||||
fun appTextStyle(
|
||||
fontSize: TextUnit,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
|
|
@ -46,186 +85,379 @@ fun appTextStyle(
|
|||
fontWeight = weight,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontSynthesis = FontSynthesis.None,
|
||||
lineHeight = fontSize * 1.4f,
|
||||
lineHeight = fontSize * 1.35f,
|
||||
letterSpacing = 0.sp,
|
||||
textAlign = textAlign,
|
||||
)
|
||||
}
|
||||
|
||||
fun appTypography(): Typography {
|
||||
val base = appTextStyle(16.sp)
|
||||
return Typography(
|
||||
bodyLarge = base,
|
||||
bodyMedium = appTextStyle(14.sp),
|
||||
bodySmall = appTextStyle(12.sp),
|
||||
titleLarge = appTextStyle(22.sp, FontWeight.Medium),
|
||||
titleMedium = appTextStyle(18.sp, FontWeight.Medium),
|
||||
titleSmall = appTextStyle(16.sp, FontWeight.Medium),
|
||||
labelLarge = appTextStyle(14.sp, FontWeight.Medium),
|
||||
labelMedium = appTextStyle(12.sp, FontWeight.Medium),
|
||||
headlineMedium = appTextStyle(24.sp, FontWeight.Medium),
|
||||
fun appTypography(): Typography = Typography(
|
||||
displaySmall = appTextStyle(34.sp, FontWeight.Medium),
|
||||
headlineLarge = appTextStyle(30.sp, FontWeight.Medium),
|
||||
headlineMedium = appTextStyle(26.sp, FontWeight.Medium),
|
||||
headlineSmall = appTextStyle(22.sp, FontWeight.Medium),
|
||||
titleLarge = appTextStyle(20.sp, FontWeight.Medium),
|
||||
titleMedium = appTextStyle(16.sp, FontWeight.Medium),
|
||||
titleSmall = appTextStyle(14.sp, FontWeight.Medium),
|
||||
bodyLarge = appTextStyle(16.sp),
|
||||
bodyMedium = appTextStyle(14.sp),
|
||||
bodySmall = appTextStyle(12.sp),
|
||||
labelLarge = appTextStyle(14.sp, FontWeight.Medium),
|
||||
labelMedium = appTextStyle(12.sp, FontWeight.Medium),
|
||||
labelSmall = appTextStyle(11.sp, FontWeight.Medium),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chrome
|
||||
|
||||
@Composable
|
||||
fun AppNavBar(vm: AppViewModel) {
|
||||
val root = vm.root
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = root == Route.Subjects,
|
||||
onClick = { vm.go(Route.Subjects) },
|
||||
icon = { SubjectsIcon(navIconColor(root == Route.Subjects)) },
|
||||
label = { Text(vm.tr("subjects_title"), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = root == Route.Stats,
|
||||
onClick = { vm.go(Route.Stats) },
|
||||
icon = { StatsIcon(navIconColor(root == Route.Stats), 22.dp) },
|
||||
label = { Text(vm.tr("stats"), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = root == Route.Settings,
|
||||
onClick = { vm.go(Route.Settings) },
|
||||
icon = { SettingsIcon(navIconColor(root == Route.Settings), 22.dp) },
|
||||
label = { Text(vm.tr("settings"), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun navIconColor(selected: Boolean): Color =
|
||||
if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
/** Tab root: large collapsing title, search action, bottom navigation. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RootScaffold(
|
||||
vm: AppViewModel,
|
||||
title: String,
|
||||
showSearch: Boolean = true,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
actions = {
|
||||
if (showSearch) {
|
||||
IconButton(onClick = { vm.searchOpen = true }) {
|
||||
Icon(Icons.Filled.Search, contentDescription = vm.tr("search_placeholder"))
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scroll,
|
||||
)
|
||||
},
|
||||
bottomBar = { AppNavBar(vm) },
|
||||
) { inner ->
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = inner.calculateTopPadding(),
|
||||
bottom = inner.calculateBottomPadding() + 16.dp,
|
||||
),
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Pushed screen: back arrow, title with optional subtitle, optional bottom action bar. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DetailScaffold(
|
||||
vm: AppViewModel,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
bottomBar: (@Composable () -> Unit)? = null,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
val scroll = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleLarge)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { vm.back() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = vm.tr("back"))
|
||||
}
|
||||
},
|
||||
scrollBehavior = scroll,
|
||||
)
|
||||
},
|
||||
bottomBar = { bottomBar?.invoke() },
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
private val Pill = RoundedCornerShape(999.dp)
|
||||
private val Card = RoundedCornerShape(20.dp)
|
||||
|
||||
/** Feedback line plus the primary action, anchored at the bottom of an exercise. */
|
||||
@Composable
|
||||
fun PrimaryButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(Pill)
|
||||
.background(palette.accent)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 22.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, style = appTextStyle(15.sp, FontWeight.Medium, color = palette.onAccent))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PillButton(label: String, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(Pill)
|
||||
.background(palette.text.copy(alpha = 0.06f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(label, style = appTextStyle(13.sp, FontWeight.Medium, color = palette.text))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconHit(onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PageTop(vm: AppViewModel, back: () -> Unit, title: String, subtitle: String? = null) {
|
||||
val p = vm.palette
|
||||
Column(Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 14.dp)) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.align(Alignment.CenterStart)) {
|
||||
IconHit(back) { BackIcon(p.text) }
|
||||
fun ActionBar(
|
||||
label: String,
|
||||
feedback: String = "",
|
||||
kind: String = "",
|
||||
enabled: Boolean = true,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 2.dp) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
if (feedback.isNotEmpty()) {
|
||||
FeedbackLine(feedback, kind)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
leading?.invoke()
|
||||
Button(onClick = onClick, enabled = enabled, modifier = Modifier.weight(1f).height(50.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
modifier = Modifier.padding(horizontal = 48.dp),
|
||||
style = appTextStyle(22.sp, FontWeight.Medium, TextAlign.Center, p.text),
|
||||
)
|
||||
}
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
subtitle,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
style = appTextStyle(14.sp, textAlign = TextAlign.Center, color = p.subtext),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lists
|
||||
|
||||
@Composable
|
||||
fun CardBox(palette: Palette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(Card)
|
||||
.background(palette.mantle)
|
||||
.border(1.dp, palette.text.copy(alpha = 0.07f), Card)
|
||||
.padding(20.dp),
|
||||
) { content() }
|
||||
fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** One navigable row: done/pending/locked state on the left, chevron on the right. */
|
||||
@Composable
|
||||
fun NavRow(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
done: Boolean = false,
|
||||
locked: Boolean = false,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
val enabled = !locked && onClick != null
|
||||
val textColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val rowModifier = if (enabled) Modifier.clickable(onClick = onClick!!) else Modifier
|
||||
ListItem(
|
||||
headlineContent = { Text(title, color = textColor) },
|
||||
supportingContent = subtitle?.takeIf { it.isNotBlank() }?.let {
|
||||
{ Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
},
|
||||
leadingContent = {
|
||||
when {
|
||||
leading != null -> leading()
|
||||
locked -> Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.outline)
|
||||
done -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
else -> Icon(Icons.Filled.RadioButtonUnchecked, null, tint = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
},
|
||||
trailingContent = if (enabled) {
|
||||
{ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = MaterialTheme.colorScheme.outline) }
|
||||
} else null,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = rowModifier,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exercise widgets
|
||||
|
||||
@Composable
|
||||
fun PrimaryButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Button(onClick = onClick, modifier = modifier) { Text(label) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeedbackLine(text: String, kind: String, palette: Palette) {
|
||||
fun PillButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
FilledTonalButton(onClick = onClick, modifier = modifier) { Text(label) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeedbackLine(text: String, kind: String) {
|
||||
if (text.isEmpty()) return
|
||||
val color = when (kind) {
|
||||
"ok" -> palette.success
|
||||
"warn" -> palette.warning
|
||||
else -> palette.error
|
||||
"ok" -> MaterialTheme.colorScheme.primary
|
||||
"warn" -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
val icon = when (kind) {
|
||||
"ok" -> Icons.Filled.CheckCircle
|
||||
"warn" -> Icons.Filled.Warning
|
||||
else -> Icons.Filled.Cancel
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, tint = color, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text, color = color, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
Text(text, color = color, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Meaning(text: String, palette: Palette, visible: Boolean) {
|
||||
fun Meaning(text: String, visible: Boolean) {
|
||||
if (!visible || text.isEmpty()) return
|
||||
Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp))
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Hint(text: String) {
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Prompt(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp, bottom = 4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WordField(
|
||||
value: String,
|
||||
onValue: (String) -> Unit,
|
||||
palette: Palette,
|
||||
placeholder: String,
|
||||
mark: Boolean? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
numeric: Boolean = false,
|
||||
) {
|
||||
val border = when (mark) {
|
||||
true -> palette.success
|
||||
false -> palette.error
|
||||
null -> palette.text.copy(alpha = 0.12f)
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(palette.surface0)
|
||||
.border(1.dp, border, RoundedCornerShape(14.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (value.isEmpty()) Text(placeholder, color = palette.overlay, fontSize = 15.sp)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValue,
|
||||
textStyle = TextStyle(color = palette.text, fontSize = 15.sp),
|
||||
cursorBrush = SolidColor(palette.accent),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Segmented(palette: Palette, content: @Composable RowScope.() -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(Pill)
|
||||
.background(palette.surface1)
|
||||
.padding(3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
content = content,
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValue,
|
||||
placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder) }) else null,
|
||||
singleLine = true,
|
||||
isError = mark == false,
|
||||
trailingIcon = when (mark) {
|
||||
true -> ({ Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) })
|
||||
false -> ({ Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error) })
|
||||
null -> null
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = if (numeric) KeyboardType.Decimal else KeyboardType.Text,
|
||||
),
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Tappable word in a bank or a built sentence. */
|
||||
@Composable
|
||||
fun SegChip(label: String, selected: Boolean, palette: Palette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(Pill)
|
||||
.background(if (selected) palette.mantle else Color.Transparent)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
fun Chip(text: String, onClick: () -> Unit) {
|
||||
SuggestionChip(onClick = onClick, label = { Text(text) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SegChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
|
||||
}
|
||||
|
||||
/** Multiple-choice answer with radio state and, after checking, a correct/wrong mark. */
|
||||
@Composable
|
||||
fun OptionRow(text: String, selected: Boolean, mark: Boolean?, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
val container = when (mark) {
|
||||
true -> MaterialTheme.colorScheme.primaryContainer
|
||||
false -> MaterialTheme.colorScheme.errorContainer
|
||||
null -> if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
color = container,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
) {
|
||||
Text(label, color = palette.text, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
||||
Row(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
when (mark) {
|
||||
true -> Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
false -> Icon(Icons.Filled.Cancel, null, tint = MaterialTheme.colorScheme.error)
|
||||
null -> RadioButton(selected = selected, onClick = null)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop zone for word ordering; visible even without words. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun Hint(text: String, palette: Palette) {
|
||||
Text(text, color = palette.subtext, fontSize = 13.sp, modifier = Modifier.padding(bottom = 8.dp))
|
||||
fun WordSlot(empty: Boolean, content: @Composable () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
) {
|
||||
FlowRow(
|
||||
Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (empty) Text("…", color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding(8.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bottom sheet with the word bank for combo/dialogue blanks. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Prompt(text: String, palette: Palette) {
|
||||
Text(text, color = palette.text, fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
fun WordPicker(title: String, pool: List<String>, onPick: (String?) -> Unit) {
|
||||
ModalBottomSheet(onDismissRequest = { onPick(null) }) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
items(pool.size) { i ->
|
||||
ListItem(
|
||||
headlineContent = { Text(pool[i]) },
|
||||
modifier = Modifier.clickable { onPick(pool[i]) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -51,6 +51,18 @@ fun StatsIcon(color: Color, iconSize: Dp = 18.dp) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SubjectsIcon(color: Color, iconSize: Dp = 22.dp) {
|
||||
DrawnIcon(iconSize) {
|
||||
val gap = size.minDimension * 0.08f
|
||||
val cell = (size.minDimension - gap) / 2f
|
||||
val r = androidx.compose.ui.geometry.CornerRadius(cell * 0.28f)
|
||||
for (iy in 0 until 2) for (ix in 0 until 2) {
|
||||
drawRoundRect(color, Offset(ix * (cell + gap), iy * (cell + gap)), Size(cell, cell), r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) {
|
||||
DrawnIcon(iconSize) {
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
package org.maturita.maturita.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.maturita.maturita.data.Palette
|
||||
|
||||
data class MapNode(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val locked: Boolean,
|
||||
val done: Boolean,
|
||||
val current: Boolean,
|
||||
val finish: Boolean = false,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PathMap(
|
||||
nodes: List<MapNode>,
|
||||
palette: Palette,
|
||||
@Suppress("UNUSED_PARAMETER") litUntil: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
@Suppress("UNUSED_PARAMETER") nodeSize: Float = 72f,
|
||||
@Suppress("UNUSED_PARAMETER") mx: Float = 90f,
|
||||
@Suppress("UNUSED_PARAMETER") my: Float = 110f,
|
||||
@Suppress("UNUSED_PARAMETER") spac: Float = 170f,
|
||||
@Suppress("UNUSED_PARAMETER") gap: Float = 170f,
|
||||
@Suppress("UNUSED_PARAMETER") wave: Float = 28f,
|
||||
@Suppress("UNUSED_PARAMETER") lift: Float = 0f,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
nodes.forEachIndexed { i, node ->
|
||||
val click = node.onClick
|
||||
CardBox(
|
||||
palette,
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(if (node.locked) 0.48f else 1f)
|
||||
.then(
|
||||
if (!node.locked && click != null) Modifier.clickable { click() }
|
||||
else Modifier,
|
||||
),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val bg = when {
|
||||
node.locked -> palette.lockedBg
|
||||
node.current || node.done -> palette.accent
|
||||
else -> palette.node
|
||||
}
|
||||
val fg = when {
|
||||
node.locked -> palette.overlay
|
||||
node.current || node.done -> palette.onAccent
|
||||
else -> palette.text
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(bg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
node.locked -> LockIcon(palette.overlay, 16.dp)
|
||||
node.done -> CheckIcon(palette.onAccent, 16.dp)
|
||||
else -> Text("${i + 1}", color = fg, fontWeight = FontWeight.Black, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
node.label,
|
||||
color = if (node.locked) palette.overlay else palette.text,
|
||||
fontWeight = if (node.current) FontWeight.Bold else FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue