Rework the mobile UIs on native components and fix layout bugs everywhere.

Android moves to Material 3 (navigation bar, app bars, list items,
bottom sheets, pager) with a real back stack. iOS hides the tab bar in
exercises so the bottom action is reachable, aligns content, and uses
system colours so themes and dark mode work. Both pick a readable
accent tint. Desktop keeps the VLSM table inside the window and gives
statistics the same header rhythm as other pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-18 15:37:15 +02:00
commit 0621311b4c
25 changed files with 1965 additions and 1836 deletions

View file

@ -13,8 +13,8 @@ android {
applicationId = "org.maturita.maturita"
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\"")

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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

View file

@ -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

View file

@ -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]) },
)
}
}
}
}

View file

@ -51,6 +51,18 @@ fun StatsIcon(color: Color, iconSize: Dp = 18.dp) {
}
}
@Composable
fun SubjectsIcon(color: Color, iconSize: Dp = 22.dp) {
DrawnIcon(iconSize) {
val gap = size.minDimension * 0.08f
val cell = (size.minDimension - gap) / 2f
val r = androidx.compose.ui.geometry.CornerRadius(cell * 0.28f)
for (iy in 0 until 2) for (ix in 0 until 2) {
drawRoundRect(color, Offset(ix * (cell + gap), iy * (cell + gap)), Size(cell, cell), r)
}
}
}
@Composable
fun SettingsIcon(color: Color, iconSize: Dp = 18.dp) {
DrawnIcon(iconSize) {

View file

@ -1,108 +0,0 @@
package org.maturita.maturita.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.maturita.maturita.data.Palette
data class MapNode(
val id: String,
val label: String,
val locked: Boolean,
val done: Boolean,
val current: Boolean,
val finish: Boolean = false,
val onClick: (() -> Unit)? = null,
)
@Composable
fun PathMap(
nodes: List<MapNode>,
palette: Palette,
@Suppress("UNUSED_PARAMETER") litUntil: Float,
modifier: Modifier = Modifier,
@Suppress("UNUSED_PARAMETER") nodeSize: Float = 72f,
@Suppress("UNUSED_PARAMETER") mx: Float = 90f,
@Suppress("UNUSED_PARAMETER") my: Float = 110f,
@Suppress("UNUSED_PARAMETER") spac: Float = 170f,
@Suppress("UNUSED_PARAMETER") gap: Float = 170f,
@Suppress("UNUSED_PARAMETER") wave: Float = 28f,
@Suppress("UNUSED_PARAMETER") lift: Float = 0f,
) {
Column(
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
nodes.forEachIndexed { i, node ->
val click = node.onClick
CardBox(
palette,
Modifier
.fillMaxWidth()
.alpha(if (node.locked) 0.48f else 1f)
.then(
if (!node.locked && click != null) Modifier.clickable { click() }
else Modifier,
),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val bg = when {
node.locked -> palette.lockedBg
node.current || node.done -> palette.accent
else -> palette.node
}
val fg = when {
node.locked -> palette.overlay
node.current || node.done -> palette.onAccent
else -> palette.text
}
Box(
Modifier
.size(44.dp)
.clip(CircleShape)
.background(bg),
contentAlignment = Alignment.Center,
) {
when {
node.locked -> LockIcon(palette.overlay, 16.dp)
node.done -> CheckIcon(palette.onAccent, 16.dp)
else -> Text("${i + 1}", color = fg, fontWeight = FontWeight.Black, fontSize = 16.sp)
}
}
Spacer(Modifier.width(12.dp))
Text(
node.label,
color = if (node.locked) palette.overlay else palette.text,
fontWeight = if (node.current) FontWeight.Bold else FontWeight.SemiBold,
fontSize = 16.sp,
modifier = Modifier.weight(1f),
)
}
}
}
}
}

File diff suppressed because it is too large Load diff