Add short transitions for navigation, answers and progress on every platform.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-08 21:13:32 +02:00
commit 59d40c886a
32 changed files with 359 additions and 121 deletions

View file

@ -34,6 +34,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
private set
var showChangelog by mutableStateOf(shouldShowChangelog())
private set
/** True when the latest navigation moved deeper; false for back and tab switches. */
var navigatingForward by mutableStateOf(true)
private set
private val stack = mutableStateListOf<Route>(Route.Subjects)
val route: Route get() = stack.last()
@ -50,11 +53,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
searchOpen = false
if (stack.last() == r) return
if (r.isRoot) {
navigatingForward = false
val target = if (r == Route.Welcome) Route.Subjects else r
stack.clear()
stack.add(target)
return
}
navigatingForward = true
if (root != Route.Subjects) {
stack.clear()
stack.addAll(r.stack())
@ -72,17 +77,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
fun openPage(page: String) {
val r = routeFromPage(page) ?: return
searchOpen = false
navigatingForward = true
stack.clear()
stack.addAll(r.stack())
}
/** Replace the current screen (lesson slides -> its exercise). */
fun replace(r: Route) {
navigatingForward = true
if (stack.size > 1) stack.removeAt(stack.lastIndex)
stack.add(r)
}
fun back() {
navigatingForward = false
when {
stack.size > 1 -> stack.removeAt(stack.lastIndex)
root != Route.Subjects -> go(Route.Subjects)

View file

@ -2,6 +2,14 @@ package org.maturita.maturita
import android.os.Bundle
import java.util.Locale
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import android.util.TypedValue
import android.view.ViewGroup
import android.widget.Button
@ -206,7 +214,24 @@ private fun MaturitaAppBody(vm: AppViewModel) {
if (vm.searchOpen) vm.searchOpen = false else vm.back()
}
Box(Modifier.fillMaxSize()) {
when (val r = vm.route) {
AnimatedContent(
targetState = vm.route,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
val tabs = targetState.isRoot && initialState.isRoot
if (tabs) {
fadeIn(tween(180)) togetherWith fadeOut(tween(140))
} else if (vm.navigatingForward) {
(fadeIn(tween(220)) + slideInHorizontally(tween(220)) { it / 5 })
.togetherWith(fadeOut(tween(150)) + slideOutHorizontally(tween(150)) { -it / 8 })
} else {
(fadeIn(tween(220)) + slideInHorizontally(tween(220)) { -it / 8 })
.togetherWith(fadeOut(tween(150)) + slideOutHorizontally(tween(150)) { it / 5 })
}
},
label = "screen",
) { route ->
when (val r = route) {
Route.Welcome, Route.Subjects -> HomeScreen(vm)
Route.Stats -> StatsScreen(vm)
Route.Settings -> SettingsScreen(vm)
@ -609,6 +634,13 @@ private fun MaturitaAppBody(vm: AppViewModel) {
is Route.BookQuiz -> LitQuizScreen(vm, r.id)
is Route.BookPlot -> PlotScreen(vm, r.id)
}
if (vm.searchOpen) SearchScreen(vm)
}
AnimatedVisibility(
visible = vm.searchOpen,
enter = fadeIn(tween(160)),
exit = fadeOut(tween(120)),
) {
Box(Modifier.fillMaxSize()) { SearchScreen(vm) }
}
}
}

View file

@ -1,5 +1,13 @@
package org.maturita.maturita.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -36,6 +44,7 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
@ -53,9 +62,11 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@ -234,8 +245,12 @@ fun ActionBar(
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
if (feedback.isNotEmpty()) {
FeedbackLine(feedback, kind)
AnimatedVisibility(
visible = feedback.isNotEmpty(),
enter = fadeIn(tween(180)) + expandVertically(),
exit = fadeOut(tween(120)) + shrinkVertically(),
) {
FeedbackLine(feedback, kind, animate = false)
Spacer(Modifier.height(10.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@ -309,8 +324,27 @@ fun PillButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit
}
@Composable
fun FeedbackLine(text: String, kind: String) {
if (text.isEmpty()) return
fun SmoothProgress(
progress: Float,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
trackColor: Color = MaterialTheme.colorScheme.surfaceVariant,
) {
val animated by animateFloatAsState(
targetValue = progress.coerceIn(0f, 1f),
animationSpec = tween(durationMillis = 380),
label = "progress",
)
LinearProgressIndicator(
progress = { animated },
modifier = modifier,
color = color,
trackColor = trackColor,
)
}
@Composable
fun FeedbackLine(text: String, kind: String, animate: Boolean = true) {
val color = when (kind) {
"ok" -> MaterialTheme.colorScheme.primary
"warn" -> MaterialTheme.colorScheme.tertiary
@ -321,6 +355,22 @@ fun FeedbackLine(text: String, kind: String) {
"warn" -> Icons.Filled.Warning
else -> Icons.Filled.Cancel
}
if (!animate) {
if (text.isEmpty()) return
FeedbackRow(text, color, icon)
return
}
AnimatedVisibility(
visible = text.isNotEmpty(),
enter = fadeIn(tween(180)) + expandVertically(),
exit = fadeOut(tween(120)) + shrinkVertically(),
) {
FeedbackRow(text, color, icon)
}
}
@Composable
private fun FeedbackRow(text: String, color: Color, icon: ImageVector) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = color, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
@ -330,13 +380,18 @@ fun FeedbackLine(text: String, kind: String) {
@Composable
fun Meaning(text: String, visible: Boolean) {
if (!visible || text.isEmpty()) return
Text(
text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
)
AnimatedVisibility(
visible = visible && text.isNotEmpty(),
enter = fadeIn(tween(180)),
exit = fadeOut(tween(120)),
) {
Text(
text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
@Composable
@ -397,11 +452,12 @@ fun SegChip(label: String, selected: Boolean, onClick: () -> Unit) {
/** 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) {
val target = when (mark) {
true -> MaterialTheme.colorScheme.primaryContainer
false -> MaterialTheme.colorScheme.errorContainer
null -> if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh
}
val container by animateColorAsState(target, tween(180), label = "option")
Surface(
onClick = onClick,
enabled = enabled,

View file

@ -33,7 +33,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@ -479,8 +478,8 @@ private fun VocabEx(vm: AppViewModel, title: String, sub: String?, spec: J, onDo
bottomBar = if (cur != null) ({ ActionBar(vm.tr("check"), fb.first, fb.second) { check() } }) else null,
) { inner ->
Column(Modifier.fillMaxSize().padding(inner).verticalScroll(rememberScrollState()).padding(16.dp)) {
LinearProgressIndicator(
progress = { if (total == 0) 0f else done / total.toFloat() },
SmoothProgress(
progress = if (total == 0) 0f else done / total.toFloat(),
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
)
Spacer(Modifier.height(6.dp))
@ -1354,7 +1353,7 @@ fun LitQuizScreen(vm: AppViewModel, id: String) {
}
} else {
val q = qs[idx]
LinearProgressIndicator(progress = { idx / qs.size.toFloat() }, modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape))
SmoothProgress(progress = idx / qs.size.toFloat(), modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape))
Spacer(Modifier.height(6.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)

View file

@ -44,7 +44,6 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
@ -116,8 +115,8 @@ fun HomeScreen(vm: AppViewModel) {
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
)
Spacer(Modifier.height(16.dp))
LinearProgressIndicator(
progress = { pct },
SmoothProgress(
progress = pct,
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
color = MaterialTheme.colorScheme.onPrimaryContainer,
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.18f),
@ -246,7 +245,7 @@ fun StatsScreen(vm: AppViewModel) {
Metric(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits), Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
LinearProgressIndicator(progress = { pct }, modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape))
SmoothProgress(progress = pct, modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape))
}
}
}
@ -262,8 +261,8 @@ fun StatsScreen(vm: AppViewModel) {
Column {
if (open && part.totalEx > 0) {
Spacer(Modifier.height(6.dp))
LinearProgressIndicator(
progress = { part.doneEx / part.totalEx.toFloat() },
SmoothProgress(
progress = part.doneEx / part.totalEx.toFloat(),
modifier = Modifier.fillMaxWidth().height(4.dp).clip(CircleShape),
)
}
@ -770,7 +769,6 @@ fun HwYearsScreen(vm: AppViewModel) {
}
}
@Composable
@Composable
fun DeHomeScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Deutsch"), vm.tr("de_home_sub")) { inner ->
@ -808,6 +806,7 @@ fun EnYearsScreen(vm: AppViewModel) {
}
}
@Composable
fun OnYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Občanská nauka"), vm.tr("on_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {