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

View file

@ -2,6 +2,14 @@ package org.maturita.maturita
import android.os.Bundle import android.os.Bundle
import java.util.Locale 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.util.TypedValue
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button import android.widget.Button
@ -206,7 +214,24 @@ private fun MaturitaAppBody(vm: AppViewModel) {
if (vm.searchOpen) vm.searchOpen = false else vm.back() if (vm.searchOpen) vm.searchOpen = false else vm.back()
} }
Box(Modifier.fillMaxSize()) { 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.Welcome, Route.Subjects -> HomeScreen(vm)
Route.Stats -> StatsScreen(vm) Route.Stats -> StatsScreen(vm)
Route.Settings -> SettingsScreen(vm) Route.Settings -> SettingsScreen(vm)
@ -609,6 +634,13 @@ private fun MaturitaAppBody(vm: AppViewModel) {
is Route.BookQuiz -> LitQuizScreen(vm, r.id) is Route.BookQuiz -> LitQuizScreen(vm, r.id)
is Route.BookPlot -> PlotScreen(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 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -36,6 +44,7 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -53,9 +62,11 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@ -234,8 +245,12 @@ fun ActionBar(
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
) { ) {
if (feedback.isNotEmpty()) { AnimatedVisibility(
FeedbackLine(feedback, kind) visible = feedback.isNotEmpty(),
enter = fadeIn(tween(180)) + expandVertically(),
exit = fadeOut(tween(120)) + shrinkVertically(),
) {
FeedbackLine(feedback, kind, animate = false)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
} }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@ -309,8 +324,27 @@ fun PillButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit
} }
@Composable @Composable
fun FeedbackLine(text: String, kind: String) { fun SmoothProgress(
if (text.isEmpty()) return 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) { val color = when (kind) {
"ok" -> MaterialTheme.colorScheme.primary "ok" -> MaterialTheme.colorScheme.primary
"warn" -> MaterialTheme.colorScheme.tertiary "warn" -> MaterialTheme.colorScheme.tertiary
@ -321,6 +355,22 @@ fun FeedbackLine(text: String, kind: String) {
"warn" -> Icons.Filled.Warning "warn" -> Icons.Filled.Warning
else -> Icons.Filled.Cancel 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) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = color, modifier = Modifier.size(20.dp)) Icon(icon, null, tint = color, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@ -330,13 +380,18 @@ fun FeedbackLine(text: String, kind: String) {
@Composable @Composable
fun Meaning(text: String, visible: Boolean) { fun Meaning(text: String, visible: Boolean) {
if (!visible || text.isEmpty()) return AnimatedVisibility(
Text( visible = visible && text.isNotEmpty(),
text, enter = fadeIn(tween(180)),
color = MaterialTheme.colorScheme.onSurfaceVariant, exit = fadeOut(tween(120)),
style = MaterialTheme.typography.bodySmall, ) {
modifier = Modifier.padding(bottom = 8.dp), Text(
) text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
)
}
} }
@Composable @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. */ /** Multiple-choice answer with radio state and, after checking, a correct/wrong mark. */
@Composable @Composable
fun OptionRow(text: String, selected: Boolean, mark: Boolean?, enabled: Boolean = true, onClick: () -> Unit) { 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 true -> MaterialTheme.colorScheme.primaryContainer
false -> MaterialTheme.colorScheme.errorContainer false -> MaterialTheme.colorScheme.errorContainer
null -> if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh null -> if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh
} }
val container by animateColorAsState(target, tween(180), label = "option")
Surface( Surface(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,

View file

@ -33,7 +33,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField 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, bottomBar = if (cur != null) ({ ActionBar(vm.tr("check"), fb.first, fb.second) { check() } }) else null,
) { inner -> ) { inner ->
Column(Modifier.fillMaxSize().padding(inner).verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().padding(inner).verticalScroll(rememberScrollState()).padding(16.dp)) {
LinearProgressIndicator( SmoothProgress(
progress = { if (total == 0) 0f else done / total.toFloat() }, progress = if (total == 0) 0f else done / total.toFloat(),
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape), modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
@ -1354,7 +1353,7 @@ fun LitQuizScreen(vm: AppViewModel, id: String) {
} }
} else { } else {
val q = qs[idx] 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)) Spacer(Modifier.height(6.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(vm.fmt("lit_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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -116,8 +115,8 @@ fun HomeScreen(vm: AppViewModel) {
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
LinearProgressIndicator( SmoothProgress(
progress = { pct }, progress = pct,
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape), modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.18f), 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)) Metric(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits), Modifier.weight(1f))
} }
Spacer(Modifier.height(16.dp)) 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 { Column {
if (open && part.totalEx > 0) { if (open && part.totalEx > 0) {
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
LinearProgressIndicator( SmoothProgress(
progress = { part.doneEx / part.totalEx.toFloat() }, progress = part.doneEx / part.totalEx.toFloat(),
modifier = Modifier.fillMaxWidth().height(4.dp).clip(CircleShape), modifier = Modifier.fillMaxWidth().height(4.dp).clip(CircleShape),
) )
} }
@ -770,7 +769,6 @@ fun HwYearsScreen(vm: AppViewModel) {
} }
} }
@Composable
@Composable @Composable
fun DeHomeScreen(vm: AppViewModel) { fun DeHomeScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Deutsch"), vm.tr("de_home_sub")) { inner -> DetailScaffold(vm, vm.tr("Deutsch"), vm.tr("de_home_sub")) { inner ->
@ -808,6 +806,7 @@ fun EnYearsScreen(vm: AppViewModel) {
} }
} }
@Composable
fun OnYearsScreen(vm: AppViewModel) { fun OnYearsScreen(vm: AppViewModel) {
DetailScaffold(vm, vm.tr("Občanská nauka"), vm.tr("on_years_sub")) { inner -> DetailScaffold(vm, vm.tr("Občanská nauka"), vm.tr("on_years_sub")) { inner ->
LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) { LazyColumn(contentPadding = PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 16.dp)) {

View file

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

View file

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

View file

@ -1,5 +1,47 @@
import SwiftUI 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) #if os(macOS)
enum MacChrome { enum MacChrome {
static let pageInset: CGFloat = 28 static let pageInset: CGFloat = 28
@ -265,21 +307,27 @@ struct FeedbackLine: View {
let text: String let text: String
let kind: String let kind: String
let palette: Palette let palette: Palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { var body: some View {
if !text.isEmpty { Group {
Label { if !text.isEmpty {
Text(text) Label {
} icon: { Text(text)
Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill") } icon: {
Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill")
}
#if os(macOS)
.font(.body.weight(.semibold))
#else
.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)))
} }
#if os(macOS)
.font(.body.weight(.semibold))
#else
.font(.subheadline.weight(.semibold))
#endif
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
} }
.animation(reduceMotion ? nil : Motion.feedback, value: text)
.animation(reduceMotion ? nil : Motion.feedback, value: kind)
} }
} }
@ -288,17 +336,23 @@ struct Meaning: View {
let palette: Palette let palette: Palette
let visible: Bool let visible: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { var body: some View {
if visible && !text.isEmpty { Group {
Text(text) if visible && !text.isEmpty {
#if os(macOS) Text(text)
.font(.body) #if os(macOS)
#else .font(.body)
.font(.footnote) #else
#endif .font(.footnote)
.foregroundStyle(.secondary) #endif
.padding(.bottom, 4) .foregroundStyle(.secondary)
.padding(.bottom, 4)
.transition(.opacity)
}
} }
.animation(reduceMotion ? nil : Motion.settle, value: visible)
} }
} }
@ -307,6 +361,7 @@ struct WordField: View {
var placeholder: String var placeholder: String
var mark: Bool? var mark: Bool?
var palette: Palette? var palette: Palette?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
@ -332,6 +387,7 @@ struct WordField: View {
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(border, lineWidth: mark == nil ? 0.5 : 1.5) .stroke(border, lineWidth: mark == nil ? 0.5 : 1.5)
) )
.animation(reduceMotion ? nil : Motion.settle, value: mark)
} }
private var border: Color { private var border: Color {
@ -426,6 +482,7 @@ struct OptionChip: View {
let selected: Bool let selected: Bool
let mark: Bool? let mark: Bool?
var action: () -> Void var action: () -> Void
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
@ -453,6 +510,8 @@ struct OptionChip: View {
.controlSize(.large) .controlSize(.large)
#endif #endif
.padding(.bottom, 4) .padding(.bottom, 4)
.animation(reduceMotion ? nil : Motion.settle, value: mark)
.animation(reduceMotion ? nil : Motion.press, value: selected)
} }
private var symbol: String { private var symbol: String {

View file

@ -35,10 +35,11 @@ struct ExerciseScaffold<Content: View>: View {
if !feedback.isEmpty { if !feedback.isEmpty {
FeedbackLine(text: feedback, kind: kind, palette: p) FeedbackLine(text: feedback, kind: kind, palette: p)
.frame(maxWidth: .infinity, alignment: .leading) .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) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(PressScaleStyle())
} }
} }
.listStyle(.plain) .listStyle(.plain)
@ -1224,9 +1224,10 @@ private struct VlsmEx: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 8) .padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading) .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() { private func maybeFinish() {
@ -1359,7 +1360,7 @@ struct LitQuizScreen: View {
}) { EmptyView() } }) { EmptyView() }
} }
} }
.animation(.default, value: answered) .animation(Motion.settle, value: answered)
} }
} }

View file

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

View file

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

View file

@ -58,8 +58,10 @@ struct MacRoot: View {
if showsUpdateAccessory { if showsUpdateAccessory {
UpdateBanner(vm: vm) UpdateBanner(vm: vm)
.padding(16) .padding(16)
.transition(.move(edge: .top).combined(with: .opacity))
} }
} }
.animation(Motion.settle, value: showsUpdateAccessory)
.onAppear { vm.checkUpdate(false) } .onAppear { vm.checkUpdate(false) }
.frame(minWidth: 980, minHeight: 640) .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; (void)user_data;
cetba_prepare_stub_quiz(); cetba_prepare_stub_quiz();
if (main_stack) 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) { 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; (void)user_data;
cetba_prepare_stub_plot(); cetba_prepare_stub_plot();
if (main_stack) if (main_stack)
gtk_stack_set_visible_child_name(main_stack, "cetbastubdej"); show_page("cetbastubdej", 1);
} }
static GtkWidget *book_rail; static GtkWidget *book_rail;
@ -1467,7 +1467,7 @@ static void on_book_node_clicked(GtkButton *button, gpointer user_data) {
rebuild_stub_about(idx); rebuild_stub_about(idx);
} }
if (main_stack && target) if (main_stack && target)
gtk_stack_set_visible_child_name(main_stack, target); show_page(target, 1);
} }
static GtkWidget *book_make_node(int i) { 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); gtk_label_set_text(GTK_LABEL(stub_title_lbl), book_defs[idx].title);
rebuild_stub_about(idx); rebuild_stub_about(idx);
if (main_stack) if (main_stack)
gtk_stack_set_visible_child_name(main_stack, "cetbastub"); show_page("cetbastub", 1);
} }
void cetba_register_search(void) { void cetba_register_search(void) {

View file

@ -416,7 +416,7 @@ static void en_open_unit(GtkButton *button, gpointer data) {
if (L->next_btn) if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next")); 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) { 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++; L->idx++;
skill_slide_apply(L); skill_slide_apply(L);
} else { } 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) if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next")); 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) { static GtkWidget *fyz_lesson_button(FyzTrack *T, int index) {
@ -460,7 +460,7 @@ static void fyz_slide_next(GtkButton *button, gpointer data) {
L->idx++; L->idx++;
fyz_slide_apply(L); fyz_slide_apply(L);
} else { } 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_lesson_notes_ensure(NetLesson *L);
void net_rescale_lesson_notes(NetLesson *L, int body, int head, int kick); void net_rescale_lesson_notes(NetLesson *L, int body, int head, int kick);
GtkWidget *make_back_button(const char *target); GtkWidget *make_back_button(const char *target);
void show_page(const char *name, int motion);
void on_nav_clicked(GtkButton *button, gpointer user_data); void on_nav_clicked(GtkButton *button, gpointer user_data);
int unit_done_count(const UnitCtx *u); int unit_done_count(const UnitCtx *u);
void progress_for_units(int first, int n, ProgressSum *out); 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++; L->idx++;
hw_slide_apply(L); hw_slide_apply(L);
} else { } 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) if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next")); 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) { 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++; L->idx++;
lit_slide_apply(L); lit_slide_apply(L);
} else { } 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"); const char *size = g_getenv("MATURITA_SIZE");
int w, h; int w, h;
gtk_stack_set_transition_type(main_stack, GTK_STACK_TRANSITION_TYPE_NONE);
if (start && *start) if (start && *start)
gtk_stack_set_visible_child_name(main_stack, start); gtk_stack_set_visible_child_name(main_stack, start);
else else
gtk_stack_set_visible_child_name(main_stack, "welcome"); 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) if (size && sscanf(size, "%dx%d", &w, &h) == 2)
gtk_window_set_default_size(window, w, h); 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) if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next")); 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) { static GtkWidget *math_lesson_button(MathTrack *T, int index) {
@ -469,7 +469,7 @@ static void math_slide_next(GtkButton *button, gpointer data) {
L->idx++; L->idx++;
math_slide_apply(L); math_slide_apply(L);
} else { } 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 */ /* 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) { void on_nav_clicked(GtkButton *button, gpointer user_data) {
const char *target = g_object_get_data(G_OBJECT(button), "target"); 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; (void)user_data;
if (target) 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 on_continue_clicked(GtkButton *button, gpointer user_data) {
(void)button; (void)button;
(void)user_data; (void)user_data;
gtk_stack_set_visible_child_name(main_stack, "subjects"); show_page("subjects", 1);
} }
GtkWidget *make_back_button(const char *target) { 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_signal_connect_swapped(back, "state-flags-changed",
G_CALLBACK(gtk_widget_queue_draw), icon); 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_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); g_signal_connect(back, "clicked", G_CALLBACK(on_nav_clicked), NULL);
return back; return back;
} }

View file

@ -195,7 +195,7 @@ void net_open_unit(GtkButton *button, gpointer data) {
return; return;
L->idx = 0; L->idx = 0;
net_slide_apply(L); 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) { void net_slide_prev(GtkButton *button, gpointer data) {
@ -220,7 +220,7 @@ void net_slide_next(GtkButton *button, gpointer data) {
L->idx++; L->idx++;
net_slide_apply(L); net_slide_apply(L);
} else { } 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) if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next")); 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) { static void on_add_node(GtkFixed *fixed, int index) {
@ -660,7 +660,7 @@ static void on_slide_next(GtkButton *button, gpointer data) {
L->idx++; L->idx++;
on_slide_apply(L); on_slide_apply(L);
} else { } 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) if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next")); 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) { static void on2_add_node(GtkFixed *fixed, int index) {
@ -566,7 +566,7 @@ static void on2_slide_next(GtkButton *button, gpointer data) {
L->idx++; L->idx++;
on2_slide_apply(L); on2_slide_apply(L);
} else { } 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) if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next")); 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) { static void on3_add_node(GtkFixed *fixed, int index) {
@ -565,7 +565,7 @@ static void on3_slide_next(GtkButton *button, gpointer data) {
L->idx++; L->idx++;
on3_slide_apply(L); on3_slide_apply(L);
} else { } 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) if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next")); 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) { static void on4_add_node(GtkFixed *fixed, int index) {
@ -566,7 +566,7 @@ static void on4_slide_next(GtkButton *button, gpointer data) {
L->idx++; L->idx++;
on4_slide_apply(L); on4_slide_apply(L);
} else { } 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) if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next")); 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) { static GtkWidget *sci_lesson_button(SciTrack *T, int index) {
@ -460,7 +460,7 @@ static void sci_slide_next(GtkButton *button, gpointer data) {
L->idx++; L->idx++;
sci_slide_apply(L); sci_slide_apply(L);
} else { } 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++; L->idx++;
skill_slide_apply(L); skill_slide_apply(L);
} else if (L->ex_page) { } 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; } SearchItem;
static GtkWidget *search_layer; static GtkWidget *search_layer;
static GtkWidget *search_reveal;
static gboolean search_want_open;
static GtkWidget *search_entry; static GtkWidget *search_entry;
static GtkWidget *search_list; static GtkWidget *search_list;
static GtkWidget *search_empty; 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)) if (!gtk_stack_get_child_by_name(main_stack, target))
return; return;
search_close(); search_close();
gtk_stack_set_visible_child_name(main_stack, target); show_page(target, 1);
} }
static void on_row_activated(GtkListBox *box, GtkListBoxRow *row, 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(); 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) { gboolean search_is_open(void) {
return search_layer && gtk_widget_get_visible(search_layer); return search_want_open;
} }
void search_close(void) { void search_close(void) {
if (search_layer) search_want_open = FALSE;
gtk_widget_set_visible(search_layer, 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) { void search_open(void) {
if (!search_layer) if (!search_reveal)
return; 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) { if (search_entry) {
gtk_editable_set_text(GTK_EDITABLE(search_entry), ""); gtk_editable_set_text(GTK_EDITABLE(search_entry), "");
gtk_widget_grab_focus(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_add_css_class(search_layer, "search-layer");
gtk_widget_set_hexpand(search_layer, TRUE); gtk_widget_set_hexpand(search_layer, TRUE);
gtk_widget_set_vexpand(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); scrim = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(scrim, "search-scrim"); 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_signal_connect(search_list, "row-activated",
G_CALLBACK(on_row_activated), NULL); 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; (void)user_data;
if (!target || !target[0]) if (!target || !target[0])
target = "subjects"; target = "subjects";
gtk_stack_set_visible_child_name(main_stack, target); show_page(target, -1);
} }
void on_stats_clicked(GtkButton *button, gpointer user_data) { 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); stats_return_page = g_strdup(cur);
} }
refresh_stats_ui(); 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) { 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); gtk_widget_set_visible(up.progress, up.state == UPDATE_DOWNLOADING);
if (up.banner) { if (up.banner) {
gtk_widget_set_visible(up.banner, banner); gtk_revealer_set_reveal_child(GTK_REVEALER(up.banner), banner);
if (banner) { if (banner) {
gtk_label_set_text(GTK_LABEL(up.banner_label), status); gtk_label_set_text(GTK_LABEL(up.banner_label), status);
gtk_button_set_label(GTK_BUTTON(up.banner_action), tr(action)); 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)btn;
(void)data; (void)data;
if (up.banner) 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. */ /* 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(); GtkWidget *later = gtk_button_new();
gtk_widget_add_css_class(bar, "update-banner"); gtk_widget_add_css_class(bar, "update-banner");
gtk_widget_set_visible(bar, FALSE);
gtk_widget_set_hexpand(label, TRUE); gtk_widget_set_hexpand(label, TRUE);
gtk_label_set_xalign(GTK_LABEL(label), 0.0); 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); g_signal_connect(later, "clicked", G_CALLBACK(on_update_dismiss), NULL);
gtk_box_append(GTK_BOX(bar), later); gtk_box_append(GTK_BOX(bar), later);
up.banner = bar;
up.banner_label = label; up.banner_label = label;
up.banner_action = action; 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. */ /* "Updates" section of the settings popover. */