mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Add short transitions for navigation, answers and progress on every platform.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
27a845c1d6
commit
59d40c886a
32 changed files with 359 additions and 121 deletions
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,21 +307,27 @@ struct FeedbackLine: View {
|
|||
let text: String
|
||||
let kind: String
|
||||
let palette: Palette
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
if !text.isEmpty {
|
||||
Label {
|
||||
Text(text)
|
||||
} icon: {
|
||||
Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill")
|
||||
Group {
|
||||
if !text.isEmpty {
|
||||
Label {
|
||||
Text(text)
|
||||
} 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 visible: Bool
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
if visible && !text.isEmpty {
|
||||
Text(text)
|
||||
#if os(macOS)
|
||||
.font(.body)
|
||||
#else
|
||||
.font(.footnote)
|
||||
#endif
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
Group {
|
||||
if visible && !text.isEmpty {
|
||||
Text(text)
|
||||
#if os(macOS)
|
||||
.font(.body)
|
||||
#else
|
||||
.font(.footnote)
|
||||
#endif
|
||||
.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 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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue