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,7 +380,11 @@ fun FeedbackLine(text: String, kind: String) {
@Composable
fun Meaning(text: String, visible: Boolean) {
if (!visible || text.isEmpty()) return
AnimatedVisibility(
visible = visible && text.isNotEmpty(),
enter = fadeIn(tween(180)),
exit = fadeOut(tween(120)),
) {
Text(
text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -338,6 +392,7 @@ fun Meaning(text: String, visible: Boolean) {
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
@Composable
fun Hint(text: String) {
@ -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)) {

View file

@ -44,6 +44,7 @@ windowcontrols button:not(:hover) image {
}
button {
background-image: none;
transition: background-color 120ms ease, color 120ms ease;
}
tooltip {
background-color: @bg_crust;

View file

@ -110,12 +110,12 @@ final class AppModel: ObservableObject {
}
func setTheme(_ id: ThemeId) {
themeId = id
withAnimation(Motion.settle) { themeId = id }
progress.themeId = id
}
func applyMode(_ m: ColorMode) {
mode = m
withAnimation(Motion.settle) { mode = m }
progress.mode = m
}
@ -285,7 +285,7 @@ final class AppModel: ObservableObject {
func dismissChangelog() {
progress.seenCommit = AppConfig.commit
showChangelog = false
withAnimation(Motion.settle) { showChangelog = false }
}
}

View file

@ -1,5 +1,47 @@
import SwiftUI
enum Motion {
static let press = Animation.easeOut(duration: 0.16)
static let settle = Animation.easeOut(duration: 0.28)
static let feedback = Animation.spring(response: 0.32, dampingFraction: 0.82)
}
/// Slight press-in for custom cards and map nodes. System buttons keep their own style.
struct PressScaleStyle: ButtonStyle {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(reduceMotion || !configuration.isPressed ? 1 : 0.98)
.animation(reduceMotion ? nil : Motion.press, value: configuration.isPressed)
}
}
private struct SoftAppear: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var shown = false
func body(content: Content) -> some View {
content
.opacity(reduceMotion || shown ? 1 : 0)
.offset(y: reduceMotion || shown ? 0 : 8)
.onAppear {
guard !reduceMotion else {
shown = true
return
}
withAnimation(Motion.settle) { shown = true }
}
}
}
extension View {
/// One short fade-in. Use on a landing surface, not on every pushed screen.
func softAppear() -> some View {
modifier(SoftAppear())
}
}
#if os(macOS)
enum MacChrome {
static let pageInset: CGFloat = 28
@ -265,8 +307,10 @@ struct FeedbackLine: View {
let text: String
let kind: String
let palette: Palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Group {
if !text.isEmpty {
Label {
Text(text)
@ -279,8 +323,12 @@ struct FeedbackLine: View {
.font(.subheadline.weight(.semibold))
#endif
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
}
}
.animation(reduceMotion ? nil : Motion.feedback, value: text)
.animation(reduceMotion ? nil : Motion.feedback, value: kind)
}
}
struct Meaning: View {
@ -288,7 +336,10 @@ struct Meaning: View {
let palette: Palette
let visible: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Group {
if visible && !text.isEmpty {
Text(text)
#if os(macOS)
@ -298,8 +349,11 @@ struct Meaning: View {
#endif
.foregroundStyle(.secondary)
.padding(.bottom, 4)
.transition(.opacity)
}
}
.animation(reduceMotion ? nil : Motion.settle, value: visible)
}
}
struct WordField: View {
@ -307,6 +361,7 @@ struct WordField: View {
var placeholder: String
var mark: Bool?
var palette: Palette?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
HStack(spacing: 8) {
@ -332,6 +387,7 @@ struct WordField: View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(border, lineWidth: mark == nil ? 0.5 : 1.5)
)
.animation(reduceMotion ? nil : Motion.settle, value: mark)
}
private var border: Color {
@ -426,6 +482,7 @@ struct OptionChip: View {
let selected: Bool
let mark: Bool?
var action: () -> Void
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Button(action: action) {
@ -453,6 +510,8 @@ struct OptionChip: View {
.controlSize(.large)
#endif
.padding(.bottom, 4)
.animation(reduceMotion ? nil : Motion.settle, value: mark)
.animation(reduceMotion ? nil : Motion.press, value: selected)
}
private var symbol: String {

View file

@ -35,10 +35,11 @@ struct ExerciseScaffold<Content: View>: View {
if !feedback.isEmpty {
FeedbackLine(text: feedback, kind: kind, palette: p)
.frame(maxWidth: .infinity, alignment: .leading)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
}
}
}
.animation(.default, value: feedback)
.animation(Motion.feedback, value: feedback)
}
}

View file

@ -330,7 +330,7 @@ struct WordPicker: View {
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
.listStyle(.plain)
@ -1224,9 +1224,10 @@ private struct VlsmEx: View {
.padding(.horizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
}
}
.animation(.default, value: fb.0)
.animation(Motion.feedback, value: fb.0)
}
private func maybeFinish() {
@ -1359,7 +1360,7 @@ struct LitQuizScreen: View {
}) { EmptyView() }
}
}
.animation(.default, value: answered)
.animation(Motion.settle, value: answered)
}
}

View file

@ -215,6 +215,7 @@ struct PathMap: View {
#else
.frame(minHeight: 420)
#endif
.softAppear()
}
@ViewBuilder
@ -321,7 +322,7 @@ struct PathMap: View {
if let dest = node.destination, !node.locked {
NavigationLink(value: dest) { content }
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
} else {
content.opacity(node.locked ? 0.55 : 1)
}

View file

@ -8,9 +8,9 @@ struct PracticeHome: View {
var body: some View {
#if os(macOS)
macHome
macHome.softAppear()
#else
iosHome
iosHome.softAppear()
#endif
}
@ -46,6 +46,7 @@ struct PracticeHome: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
ProgressView(value: pct)
.animation(Motion.settle, value: pct)
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
.font(.subheadline)
.foregroundStyle(.secondary)
@ -91,7 +92,7 @@ struct PracticeHome: View {
NavigationLink(value: dest) {
subjectTileBody(s, open: true, progress: progress, part: part)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
} else {
subjectTileBody(s, open: false, progress: 0, part: part)
}
@ -108,6 +109,7 @@ struct PracticeHome: View {
Spacer(minLength: 0)
if open && part.totalEx > 0 {
ProgressView(value: progress)
.animation(Motion.settle, value: progress)
Text(vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx))
.font(.subheadline)
.foregroundStyle(.secondary)
@ -146,6 +148,7 @@ struct PracticeHome: View {
.font(.subheadline)
.foregroundStyle(.secondary)
ProgressView(value: pct)
.animation(Motion.settle, value: pct)
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
.font(.footnote)
.foregroundStyle(.secondary)
@ -415,7 +418,7 @@ struct SettingsScreen: View {
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
.softSurface(cornerRadius: 16)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
}
@ -476,6 +479,7 @@ struct StatsScreen: View {
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
}
ProgressView(value: pct)
.animation(Motion.settle, value: pct)
}
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
@ -505,6 +509,7 @@ struct StatsScreen: View {
}
if open && part.totalEx > 0 {
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
.animation(Motion.settle, value: part.doneEx)
}
}
} icon: {
@ -773,7 +778,7 @@ struct SearchScreen: View {
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
.softSurface(cornerRadius: 16)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
.disabled(hit.locked)
}
}
@ -848,7 +853,7 @@ struct SearchScreen: View {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
.accessibilityLabel("Clear")
}
}
@ -1205,7 +1210,7 @@ struct NetYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("net_years_title"))
.navigationSubtitle(vm.tr("net_years_sub"))
@ -1246,7 +1251,7 @@ struct HwYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Technické vybavení"))
.navigationSubtitle(vm.tr("hw_years_sub"))
@ -1288,7 +1293,7 @@ struct DeHomeScreen: View {
NavigationLink(value: rows[i].3) {
MacLinkCard(title: vm.tr(rows[i].1), subtitle: vm.tr(rows[i].2), badge: rows[i].0)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Deutsch"))
.navigationSubtitle(vm.tr("de_home_sub"))
@ -1324,7 +1329,7 @@ struct EnYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("English"))
.navigationSubtitle(vm.tr("en_years_sub"))
@ -1358,7 +1363,7 @@ struct OnYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Občanská nauka"))
.navigationSubtitle(vm.tr("on_years_sub"))
@ -1472,9 +1477,14 @@ struct SlidesScreen: View {
#else
if slides.indices.contains(idx) {
slidePage(slides[idx])
.id(idx)
.transition(.opacity)
}
#endif
}
#if os(macOS)
.animation(Motion.settle, value: idx)
#endif
.detailScreen(title, subtitle: subtitle)
.glassBottomBar {
VStack(spacing: 10) {
@ -1485,7 +1495,7 @@ struct SlidesScreen: View {
.frame(width: i == idx ? 18 : 6, height: 6)
}
}
.animation(.default, value: idx)
.animation(Motion.settle, value: idx)
HStack(spacing: 10) {
if idx > 0 {
Button {
@ -1668,7 +1678,7 @@ struct LitYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Literatura"))
.navigationSubtitle(vm.tr("lit_years_sub"))
@ -1710,7 +1720,7 @@ struct MatYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: years[i].3)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Matematika"))
.navigationSubtitle(vm.tr("mat_years_sub"))
@ -1754,7 +1764,7 @@ struct FyzYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Fyzika"))
.navigationSubtitle(vm.tr("fyz_years_sub"))
@ -1790,12 +1800,12 @@ struct SciMapScreen: View {
NavigationLink(value: Route.chemMap) {
MacLinkCard(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub"), systemImage: "flask.fill")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
} else {
NavigationLink(value: Route.bioMap) {
MacLinkCard(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub"), systemImage: "leaf.fill")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
.navigationTitle(vm.tr("Základy Přírodopisných věd"))
@ -1835,17 +1845,17 @@ struct CzechMapScreen: View {
NavigationLink(value: Route.mluvnice) {
MacLinkCard(title: vm.tr("Mluvnice"), systemImage: "textformat")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
case 2:
NavigationLink(value: Route.readingList) {
MacLinkCard(title: vm.tr("Maturitní četba"), systemImage: "books.vertical")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
default:
NavigationLink(value: Route.litYears) {
MacLinkCard(title: vm.tr("Literatura"), systemImage: "book.closed")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
.navigationTitle(vm.tr("Český jazyk a literatura"))
@ -1932,7 +1942,7 @@ struct BookListScreen: View {
.frame(maxWidth: .infinity, minHeight: 168, alignment: .topLeading)
.softSurface(cornerRadius: 18)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
.padding(MacChrome.pageInset)
@ -2070,7 +2080,7 @@ struct BookScreen: View {
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
if hasPlot {
NavigationLink(value: Route.bookPlot(id)) {
@ -2082,7 +2092,7 @@ struct BookScreen: View {
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
#else
@ -2096,7 +2106,7 @@ struct BookScreen: View {
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
if hasPlot {
NavigationLink(value: Route.bookPlot(id)) {
@ -2108,7 +2118,7 @@ struct BookScreen: View {
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
#endif
}

View file

@ -58,8 +58,10 @@ struct MacRoot: View {
if showsUpdateAccessory {
UpdateBanner(vm: vm)
.padding(16)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(Motion.settle, value: showsUpdateAccessory)
.onAppear { vm.checkUpdate(false) }
.frame(minWidth: 980, minHeight: 640)
}

View file

@ -1282,7 +1282,7 @@ static void on_stub_quiz_clicked(GtkButton *button, gpointer user_data) {
(void)user_data;
cetba_prepare_stub_quiz();
if (main_stack)
gtk_stack_set_visible_child_name(main_stack, "cetbastubquiz");
show_page("cetbastubquiz", 1);
}
static void on_stub_plot_clicked(GtkButton *button, gpointer user_data) {
@ -1290,7 +1290,7 @@ static void on_stub_plot_clicked(GtkButton *button, gpointer user_data) {
(void)user_data;
cetba_prepare_stub_plot();
if (main_stack)
gtk_stack_set_visible_child_name(main_stack, "cetbastubdej");
show_page("cetbastubdej", 1);
}
static GtkWidget *book_rail;
@ -1467,7 +1467,7 @@ static void on_book_node_clicked(GtkButton *button, gpointer user_data) {
rebuild_stub_about(idx);
}
if (main_stack && target)
gtk_stack_set_visible_child_name(main_stack, target);
show_page(target, 1);
}
static GtkWidget *book_make_node(int i) {
@ -1810,7 +1810,7 @@ void cetba_open_stub(int idx) {
gtk_label_set_text(GTK_LABEL(stub_title_lbl), book_defs[idx].title);
rebuild_stub_about(idx);
if (main_stack)
gtk_stack_set_visible_child_name(main_stack, "cetbastub");
show_page("cetbastub", 1);
}
void cetba_register_search(void) {

View file

@ -416,7 +416,7 @@ static void en_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
static void en_add_node(EngMap *m, GtkFixed *fixed, int index) {
@ -699,7 +699,7 @@ static void en_slide_next(GtkButton *button, gpointer data) {
L->idx++;
skill_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -220,7 +220,7 @@ static void fyz_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
static GtkWidget *fyz_lesson_button(FyzTrack *T, int index) {
@ -460,7 +460,7 @@ static void fyz_slide_next(GtkButton *button, gpointer data) {
L->idx++;
fyz_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -1199,6 +1199,7 @@ void draw_bulb_icon(GtkDrawingArea *area, cairo_t *cr,
void net_lesson_notes_ensure(NetLesson *L);
void net_rescale_lesson_notes(NetLesson *L, int body, int head, int kick);
GtkWidget *make_back_button(const char *target);
void show_page(const char *name, int motion);
void on_nav_clicked(GtkButton *button, gpointer user_data);
int unit_done_count(const UnitCtx *u);
void progress_for_units(int first, int n, ProgressSum *out);

View file

@ -661,7 +661,7 @@ void hw_slide_next(GtkButton *button, gpointer data) {
L->idx++;
hw_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -377,7 +377,7 @@ static void lit_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
static void lit_add_node(LitTrack *T, GtkFixed *fixed, int index) {
@ -634,7 +634,7 @@ static void lit_slide_next(GtkButton *button, gpointer data) {
L->idx++;
lit_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -736,10 +736,13 @@ void activate(GtkApplication *app, gpointer user_data) {
const char *size = g_getenv("MATURITA_SIZE");
int w, h;
gtk_stack_set_transition_type(main_stack, GTK_STACK_TRANSITION_TYPE_NONE);
if (start && *start)
gtk_stack_set_visible_child_name(main_stack, start);
else
gtk_stack_set_visible_child_name(main_stack, "welcome");
gtk_stack_set_transition_type(main_stack, GTK_STACK_TRANSITION_TYPE_CROSSFADE);
gtk_stack_set_transition_duration(main_stack, 180);
if (size && sscanf(size, "%dx%d", &w, &h) == 2)
gtk_window_set_default_size(window, w, h);
}

View file

@ -226,7 +226,7 @@ static void math_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
static GtkWidget *math_lesson_button(MathTrack *T, int index) {
@ -469,7 +469,7 @@ static void math_slide_next(GtkButton *button, gpointer data) {
L->idx++;
math_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -4,17 +4,40 @@
/* Navigation */
/* ------------------------------------------------------------------ */
/* motion: -1 slides back, 0 crossfades, 1 slides forward. */
void show_page(const char *name, int motion) {
GtkStackTransitionType type = GTK_STACK_TRANSITION_TYPE_CROSSFADE;
guint ms = 180;
if (!main_stack || !name || !name[0])
return;
if (!gtk_stack_get_child_by_name(main_stack, name))
return;
if (motion > 0) {
type = GTK_STACK_TRANSITION_TYPE_SLIDE_LEFT;
ms = 200;
} else if (motion < 0) {
type = GTK_STACK_TRANSITION_TYPE_SLIDE_RIGHT;
ms = 200;
}
gtk_stack_set_transition_type(main_stack, type);
gtk_stack_set_transition_duration(main_stack, ms);
gtk_stack_set_visible_child_name(main_stack, name);
}
void on_nav_clicked(GtkButton *button, gpointer user_data) {
const char *target = g_object_get_data(G_OBJECT(button), "target");
int back = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "nav-back"));
(void)user_data;
if (target)
gtk_stack_set_visible_child_name(main_stack, target);
show_page(target, back ? -1 : 1);
}
void on_continue_clicked(GtkButton *button, gpointer user_data) {
(void)button;
(void)user_data;
gtk_stack_set_visible_child_name(main_stack, "subjects");
show_page("subjects", 1);
}
GtkWidget *make_back_button(const char *target) {
@ -31,6 +54,7 @@ GtkWidget *make_back_button(const char *target) {
g_signal_connect_swapped(back, "state-flags-changed",
G_CALLBACK(gtk_widget_queue_draw), icon);
g_object_set_data_full(G_OBJECT(back), "target", g_strdup(target), g_free);
g_object_set_data(G_OBJECT(back), "nav-back", GINT_TO_POINTER(1));
g_signal_connect(back, "clicked", G_CALLBACK(on_nav_clicked), NULL);
return back;
}

View file

@ -195,7 +195,7 @@ void net_open_unit(GtkButton *button, gpointer data) {
return;
L->idx = 0;
net_slide_apply(L);
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
void net_slide_prev(GtkButton *button, gpointer data) {
@ -220,7 +220,7 @@ void net_slide_next(GtkButton *button, gpointer data) {
L->idx++;
net_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -266,7 +266,7 @@ static void on_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
static void on_add_node(GtkFixed *fixed, int index) {
@ -660,7 +660,7 @@ static void on_slide_next(GtkButton *button, gpointer data) {
L->idx++;
on_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -278,7 +278,7 @@ static void on2_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
static void on2_add_node(GtkFixed *fixed, int index) {
@ -566,7 +566,7 @@ static void on2_slide_next(GtkButton *button, gpointer data) {
L->idx++;
on2_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -277,7 +277,7 @@ static void on3_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
static void on3_add_node(GtkFixed *fixed, int index) {
@ -565,7 +565,7 @@ static void on3_slide_next(GtkButton *button, gpointer data) {
L->idx++;
on3_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -278,7 +278,7 @@ static void on4_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
static void on4_add_node(GtkFixed *fixed, int index) {
@ -566,7 +566,7 @@ static void on4_slide_next(GtkButton *button, gpointer data) {
L->idx++;
on4_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -191,7 +191,7 @@ static void sci_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
gtk_stack_set_visible_child_name(main_stack, L->unit_page);
show_page(L->unit_page, 1);
}
static GtkWidget *sci_lesson_button(SciTrack *T, int index) {
@ -460,7 +460,7 @@ static void sci_slide_next(GtkButton *button, gpointer data) {
L->idx++;
sci_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -205,7 +205,7 @@ static void qy_slide_next(GtkButton *button, gpointer data) {
L->idx++;
skill_slide_apply(L);
} else if (L->ex_page) {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
show_page(L->ex_page, 1);
}
}

View file

@ -16,6 +16,8 @@ typedef struct {
} SearchItem;
static GtkWidget *search_layer;
static GtkWidget *search_reveal;
static gboolean search_want_open;
static GtkWidget *search_entry;
static GtkWidget *search_list;
static GtkWidget *search_empty;
@ -472,7 +474,7 @@ static void search_go(const char *target) {
if (!gtk_stack_get_child_by_name(main_stack, target))
return;
search_close();
gtk_stack_set_visible_child_name(main_stack, target);
show_page(target, 1);
}
static void on_row_activated(GtkListBox *box, GtkListBoxRow *row,
@ -641,19 +643,40 @@ static void on_scrim_clicked(GtkGestureClick *g, gint n_press, gdouble x,
search_close();
}
static void on_search_revealed(GObject *obj, GParamSpec *pspec, gpointer data) {
GtkRevealer *reveal = GTK_REVEALER(obj);
(void)pspec;
(void)data;
if (!gtk_revealer_get_reveal_child(reveal) &&
!gtk_revealer_get_child_revealed(reveal))
gtk_widget_set_visible(GTK_WIDGET(reveal), FALSE);
}
gboolean search_is_open(void) {
return search_layer && gtk_widget_get_visible(search_layer);
return search_want_open;
}
void search_close(void) {
if (search_layer)
gtk_widget_set_visible(search_layer, FALSE);
search_want_open = FALSE;
if (search_reveal)
gtk_revealer_set_reveal_child(GTK_REVEALER(search_reveal), FALSE);
}
static gboolean reveal_search(gpointer data) {
(void)data;
if (search_want_open && search_reveal)
gtk_revealer_set_reveal_child(GTK_REVEALER(search_reveal), TRUE);
return G_SOURCE_REMOVE;
}
void search_open(void) {
if (!search_layer)
if (!search_reveal)
return;
gtk_widget_set_visible(search_layer, TRUE);
search_want_open = TRUE;
gtk_widget_set_visible(search_reveal, TRUE);
/* Wait a frame so the overlay is mapped and the fade actually runs. */
g_idle_add(reveal_search, NULL);
if (search_entry) {
gtk_editable_set_text(GTK_EDITABLE(search_entry), "");
gtk_widget_grab_focus(search_entry);
@ -711,7 +734,6 @@ void search_attach(GtkOverlay *overlay) {
gtk_widget_add_css_class(search_layer, "search-layer");
gtk_widget_set_hexpand(search_layer, TRUE);
gtk_widget_set_vexpand(search_layer, TRUE);
gtk_widget_set_visible(search_layer, FALSE);
scrim = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(scrim, "search-scrim");
@ -763,5 +785,16 @@ void search_attach(GtkOverlay *overlay) {
g_signal_connect(search_list, "row-activated",
G_CALLBACK(on_row_activated), NULL);
gtk_overlay_add_overlay(overlay, search_layer);
search_reveal = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(search_reveal),
GTK_REVEALER_TRANSITION_TYPE_CROSSFADE);
gtk_revealer_set_transition_duration(GTK_REVEALER(search_reveal), 160);
gtk_revealer_set_reveal_child(GTK_REVEALER(search_reveal), FALSE);
gtk_widget_set_hexpand(search_reveal, TRUE);
gtk_widget_set_vexpand(search_reveal, TRUE);
gtk_widget_set_visible(search_reveal, FALSE);
gtk_revealer_set_child(GTK_REVEALER(search_reveal), search_layer);
g_signal_connect(search_reveal, "notify::child-revealed",
G_CALLBACK(on_search_revealed), NULL);
gtk_overlay_add_overlay(overlay, search_reveal);
}

View file

@ -212,7 +212,7 @@ void on_stats_back_clicked(GtkButton *button, gpointer user_data) {
(void)user_data;
if (!target || !target[0])
target = "subjects";
gtk_stack_set_visible_child_name(main_stack, target);
show_page(target, -1);
}
void on_stats_clicked(GtkButton *button, gpointer user_data) {
@ -227,7 +227,7 @@ void on_stats_clicked(GtkButton *button, gpointer user_data) {
stats_return_page = g_strdup(cur);
}
refresh_stats_ui();
gtk_stack_set_visible_child_name(main_stack, "stats");
show_page("stats", 0);
}
GtkWidget *stats_metric_card(const char *label_key, GtkWidget **value_out) {

View file

@ -413,7 +413,7 @@ static void update_refresh_ui(void) {
gtk_widget_set_visible(up.progress, up.state == UPDATE_DOWNLOADING);
if (up.banner) {
gtk_widget_set_visible(up.banner, banner);
gtk_revealer_set_reveal_child(GTK_REVEALER(up.banner), banner);
if (banner) {
gtk_label_set_text(GTK_LABEL(up.banner_label), status);
gtk_button_set_label(GTK_BUTTON(up.banner_action), tr(action));
@ -1019,7 +1019,7 @@ static void on_update_dismiss(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
if (up.banner)
gtk_widget_set_visible(up.banner, FALSE);
gtk_revealer_set_reveal_child(GTK_REVEALER(up.banner), FALSE);
}
/* Strip shown above the page stack once an update is waiting. */
@ -1030,7 +1030,6 @@ GtkWidget *build_update_banner(void) {
GtkWidget *later = gtk_button_new();
gtk_widget_add_css_class(bar, "update-banner");
gtk_widget_set_visible(bar, FALSE);
gtk_widget_set_hexpand(label, TRUE);
gtk_label_set_xalign(GTK_LABEL(label), 0.0);
@ -1048,10 +1047,19 @@ GtkWidget *build_update_banner(void) {
g_signal_connect(later, "clicked", G_CALLBACK(on_update_dismiss), NULL);
gtk_box_append(GTK_BOX(bar), later);
up.banner = bar;
up.banner_label = label;
up.banner_action = action;
return bar;
{
GtkWidget *reveal = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(reveal),
GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN);
gtk_revealer_set_transition_duration(GTK_REVEALER(reveal), 180);
gtk_revealer_set_reveal_child(GTK_REVEALER(reveal), FALSE);
gtk_revealer_set_child(GTK_REVEALER(reveal), bar);
up.banner = reveal;
return reveal;
}
}
/* "Updates" section of the settings popover. */