Give the Mac app Apple's Liquid Glass chrome with a system sidebar and glass cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-21 12:39:06 +02:00
commit 1569df7e13
5 changed files with 192 additions and 120 deletions

View file

@ -49,12 +49,12 @@ struct PrimaryButton: View {
.font(.body.weight(.semibold)) .font(.body.weight(.semibold))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
#if os(iOS)
.buttonStyle(.glassProminent) .buttonStyle(.glassProminent)
#if os(macOS)
.controlSize(.extraLarge)
#else #else
.buttonStyle(.borderedProminent)
#endif
.controlSize(.large) .controlSize(.large)
#endif
} }
} }
@ -75,7 +75,7 @@ struct GlassCard<Content: View>: View {
content() content()
.padding(16) .padding(16)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) .glassEffect(.regular, in: .rect(cornerRadius: 26, style: .continuous))
} }
} }
@ -288,6 +288,33 @@ extension View {
.padding() .padding()
#endif #endif
} }
/// macOS content lists use the inset style so they sit under the floating
/// glass sidebar instead of looking like a second source list.
@ViewBuilder
func appListStyle() -> some View {
#if os(macOS)
listStyle(.inset)
#else
self
#endif
}
@ViewBuilder
func appFormStyle() -> some View {
#if os(macOS)
formStyle(.grouped)
.scenePadding()
#else
self
#endif
}
/// Pin a primary action above scrolling content using the system scroll
/// edge effect instead of an opaque bar.
func glassBottomBar<Bar: View>(@ViewBuilder _ bar: () -> Bar) -> some View {
safeAreaBar(edge: .bottom, spacing: 8, content: bar)
}
} }
/// Bottom action area used by exercises and lesson slides. /// Bottom action area used by exercises and lesson slides.
@ -304,7 +331,6 @@ struct BottomAction<Extra: View>: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 8) .padding(.top, 8)
.padding(.bottom, 4) .padding(.bottom, 4)
.background(.bar)
} }
} }
@ -322,19 +348,20 @@ func subjectSymbol(_ icon: String) -> String {
struct SubjectIcon: View { struct SubjectIcon: View {
let icon: String let icon: String
var open = true var open = true
var size: CGFloat = 26
var body: some View { var body: some View {
Group { Group {
switch icon { switch icon {
case "de": GermanFlag(size: 26) case "de": GermanFlag(size: size)
case "cz": CzechFlag(size: 26) case "cz": CzechFlag(size: size)
default: default:
Image(systemName: subjectSymbol(icon)) Image(systemName: subjectSymbol(icon))
.font(.title3) .font(.system(size: size * 0.72, weight: .semibold))
.foregroundStyle(open ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary)) .foregroundStyle(open ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary))
} }
} }
.frame(width: 28, height: 28) .frame(width: size, height: size)
.opacity(open ? 1 : 0.6) .opacity(open ? 1 : 0.6)
} }
} }

View file

@ -18,7 +18,7 @@ struct ExerciseScaffold<Content: View>: View {
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.detailScreen(title, subtitle: sub) .detailScreen(title, subtitle: sub)
.safeAreaInset(edge: .bottom) { .glassBottomBar {
BottomAction(label: action, action: onAction) { BottomAction(label: action, action: onAction) {
if !feedback.isEmpty { if !feedback.isEmpty {
FeedbackLine(text: feedback, kind: kind, palette: p) FeedbackLine(text: feedback, kind: kind, palette: p)
@ -601,7 +601,7 @@ private struct VocabEx: View {
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.detailScreen(title, subtitle: sub) .detailScreen(title, subtitle: sub)
.safeAreaInset(edge: .bottom) { .glassBottomBar {
if index < cards.count { if index < cards.count {
BottomAction(label: vm.tr("check"), action: { check(cards[index]) }) { EmptyView() } BottomAction(label: vm.tr("check"), action: { check(cards[index]) }) { EmptyView() }
} }

View file

@ -741,12 +741,12 @@ private struct VlsmEx: View {
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.detailScreen(vm.tr("net_ex_title")) .detailScreen(vm.tr("net_ex_title"))
.safeAreaInset(edge: .bottom) { .glassBottomBar {
if !fb.0.isEmpty { if !fb.0.isEmpty {
FeedbackLine(text: fb.0, kind: fb.1, palette: p) FeedbackLine(text: fb.0, kind: fb.1, palette: p)
.padding() .padding(.horizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(.bar)
} }
} }
.animation(.default, value: fb.0) .animation(.default, value: fb.0)
@ -842,7 +842,7 @@ struct LitQuizScreen: View {
.exerciseContent() .exerciseContent()
} }
.detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub"))) .detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
.safeAreaInset(edge: .bottom) { .glassBottomBar {
if finished { if finished {
BottomAction(label: vm.tr("lit_again"), action: { BottomAction(label: vm.tr("lit_again"), action: {
idx = 0; score = 0; answered = false; finished = false; picked = -1 idx = 0; score = 0; answered = false; finished = false; picked = -1

View file

@ -4,6 +4,100 @@ struct PracticeHome: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
var body: some View { var body: some View {
#if os(macOS)
macHome
#else
iosHome
#endif
}
#if os(macOS)
private var macHome: some View {
let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
return ScrollView {
VStack(alignment: .leading, spacing: 28) {
if vm.showChangelog {
ChangelogCard(vm: vm)
}
GlassCard {
VStack(alignment: .leading, spacing: 12) {
Text(vm.tr("welcome_title"))
.font(.largeTitle.bold())
Text(vm.tr(Self.welcomeBodyKey))
.font(.title3)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
ProgressView(value: pct)
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 16) {
Text(vm.tr("subjects_title"))
.font(.title2.weight(.semibold))
LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 16)], spacing: 16) {
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
subjectTile(s)
}
}
}
}
.padding(28)
.frame(maxWidth: 920, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.backgroundExtensionEffect()
.navigationTitle("Maturita")
}
@ViewBuilder
private func subjectTile(_ s: J) -> some View {
let open = s.bool("open")
let dest = s.strOrNull("target").flatMap(routeFromPage)
let part = subjectSum(vm, s)
let progress = open && part.totalEx > 0 ? Double(part.doneEx) / Double(part.totalEx) : 0
Group {
if open, let dest, dest != .subjects, dest != .welcome {
NavigationLink(value: dest) {
subjectTileBody(s, open: true, progress: progress, part: part)
}
.buttonStyle(.plain)
} else {
subjectTileBody(s, open: false, progress: 0, part: part)
}
}
}
private func subjectTileBody(_ s: J, open: Bool, progress: Double, part: ProgressSum) -> some View {
VStack(alignment: .leading, spacing: 14) {
SubjectIcon(icon: s.str("icon"), open: open, size: 36)
Text(vm.tr(s.str("key")))
.font(.headline)
.foregroundStyle(open ? .primary : .secondary)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
if open && part.totalEx > 0 {
ProgressView(value: progress)
Text(vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
} else if !open {
Text(vm.tr("stats_locked"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(18)
.frame(maxWidth: .infinity, minHeight: 148, alignment: .topLeading)
.glassEffect(.regular, in: .rect(cornerRadius: 24, style: .continuous))
}
#endif
#if os(iOS)
private var iosHome: some View {
let sum = summarize(vm.content, vm.progress) let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx) let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
List { List {
@ -50,17 +144,7 @@ struct PracticeHome: View {
} }
} }
.navigationTitle("Maturita") .navigationTitle("Maturita")
#if os(iOS)
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
#endif
}
private static var welcomeBodyKey: String {
#if os(macOS)
"welcome_body_macos"
#else
"welcome_body_ios"
#endif
} }
private func subjectLabel(_ s: J) -> some View { private func subjectLabel(_ s: J) -> some View {
@ -75,6 +159,15 @@ struct PracticeHome: View {
SubjectIcon(icon: s.str("icon"), open: s.bool("open")) SubjectIcon(icon: s.str("icon"), open: s.bool("open"))
} }
} }
#endif
private static var welcomeBodyKey: String {
#if os(macOS)
"welcome_body_macos"
#else
"welcome_body_ios"
#endif
}
} }
struct ChangelogCard: View { struct ChangelogCard: View {
@ -99,7 +192,7 @@ struct ChangelogCard: View {
ChangelogLines(index: index, lines: changelogLines(block, langKey)) ChangelogLines(index: index, lines: changelogLines(block, langKey))
} }
Button(vm.tr("changelog_dismiss")) { vm.dismissChangelog() } Button(vm.tr("changelog_dismiss")) { vm.dismissChangelog() }
.buttonStyle(.borderedProminent) .buttonStyle(.glassProminent)
.controlSize(.small) .controlSize(.small)
} }
} }
@ -171,6 +264,7 @@ struct SettingsScreen: View {
} }
} }
.navigationTitle(vm.tr("settings_title")) .navigationTitle(vm.tr("settings_title"))
.appFormStyle()
} }
private var modeBinding: Binding<ColorMode> { private var modeBinding: Binding<ColorMode> {
@ -237,6 +331,7 @@ struct StatsScreen: View {
} }
} }
.navigationTitle(vm.tr("stats_title")) .navigationTitle(vm.tr("stats_title"))
.appListStyle()
} }
private func stat(_ label: String, _ value: String) -> some View { private func stat(_ label: String, _ value: String) -> some View {
@ -296,8 +391,9 @@ struct SearchScreen: View {
} }
} }
} }
.navigationTitle(vm.tr("search_placeholder")) .navigationTitle(vm.tr("search"))
.searchable(text: $vm.searchQuery, prompt: vm.tr("search_placeholder")) .searchable(text: $vm.searchQuery, prompt: vm.tr("search_placeholder"))
.appListStyle()
} }
} }
@ -372,6 +468,7 @@ struct RoadmapScreen: View {
} }
.navigationTitle(vm.tr("roadmap_title")) .navigationTitle(vm.tr("roadmap_title"))
.navigationSubtitle(vm.tr("roadmap_sub")) .navigationSubtitle(vm.tr("roadmap_sub"))
.appListStyle()
} }
} }
@ -400,6 +497,7 @@ struct UnitMapScreen: View {
} }
.navigationTitle(u.str("title")) .navigationTitle(u.str("title"))
.navigationSubtitle(vm.tr(u.str("sub"))) .navigationSubtitle(vm.tr(u.str("sub")))
.appListStyle()
} }
} }
} }
@ -429,6 +527,7 @@ struct NetYearsScreen: View {
} }
.navigationTitle(vm.tr("net_years_title")) .navigationTitle(vm.tr("net_years_title"))
.navigationSubtitle(vm.tr("net_years_sub")) .navigationSubtitle(vm.tr("net_years_sub"))
.appListStyle()
} }
} }
@ -456,6 +555,7 @@ struct LessonListScreen: View {
} }
.navigationTitle(title) .navigationTitle(title)
.navigationSubtitle(subtitle) .navigationSubtitle(subtitle)
.appListStyle()
} }
} }
@ -483,7 +583,7 @@ struct SlidesScreen: View {
#endif #endif
} }
.detailScreen(title, subtitle: subtitle) .detailScreen(title, subtitle: subtitle)
.safeAreaInset(edge: .bottom) { .glassBottomBar {
VStack(spacing: 10) { VStack(spacing: 10) {
HStack(spacing: 6) { HStack(spacing: 6) {
ForEach(slides.indices, id: \.self) { i in ForEach(slides.indices, id: \.self) { i in
@ -501,11 +601,7 @@ struct SlidesScreen: View {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.frame(minWidth: 24) .frame(minWidth: 24)
} }
#if os(iOS)
.buttonStyle(.glass) .buttonStyle(.glass)
#else
.buttonStyle(.bordered)
#endif
.controlSize(.large) .controlSize(.large)
} }
if idx < slides.count - 1 { if idx < slides.count - 1 {
@ -522,7 +618,6 @@ struct SlidesScreen: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 8) .padding(.top, 8)
.padding(.bottom, 4) .padding(.bottom, 4)
.background(.bar)
} }
} }
@ -568,6 +663,7 @@ struct CzechMapScreen: View {
} }
.navigationTitle(vm.tr("Český jazyk a literatura")) .navigationTitle(vm.tr("Český jazyk a literatura"))
.navigationSubtitle(vm.tr("czech_sub")) .navigationSubtitle(vm.tr("czech_sub"))
.appListStyle()
} }
} }
@ -591,6 +687,7 @@ struct BookListScreen: View {
} }
.navigationTitle(vm.tr("Maturitní četba")) .navigationTitle(vm.tr("Maturitní četba"))
.navigationSubtitle(vm.tr("reading_sub")) .navigationSubtitle(vm.tr("reading_sub"))
.appListStyle()
} }
} }
@ -631,6 +728,7 @@ struct BookScreen: View {
} }
.navigationTitle(b.str("title")) .navigationTitle(b.str("title"))
.navigationSubtitle(vm.tr(b.str("subKey"))) .navigationSubtitle(vm.tr(b.str("subKey")))
.appListStyle()
} }
} }
} }

View file

@ -10,6 +10,7 @@ struct MaturitaApp: App {
} }
.defaultSize(width: 1100, height: 720) .defaultSize(width: 1100, height: 720)
.windowToolbarStyle(.unified) .windowToolbarStyle(.unified)
.windowStyle(.automatic)
.commands { .commands {
CommandGroup(replacing: .newItem) {} CommandGroup(replacing: .newItem) {}
CommandGroup(after: .sidebar) { CommandGroup(after: .sidebar) {
@ -35,6 +36,7 @@ struct MaturitaApp: App {
Settings { Settings {
SettingsScreen(vm: vm) SettingsScreen(vm: vm)
.formStyle(.grouped)
.frame(minWidth: 420, minHeight: 460) .frame(minWidth: 420, minHeight: 460)
.tint(vm.palette.tint) .tint(vm.palette.tint)
.preferredColorScheme(vm.mode == .dark ? .dark : .light) .preferredColorScheme(vm.mode == .dark ? .dark : .light)
@ -46,61 +48,53 @@ struct MacRoot: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
var body: some View { var body: some View {
NavigationSplitView { tabs
MacSidebar(vm: vm) .tint(vm.palette.tint)
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 320) .preferredColorScheme(vm.mode == .dark ? .dark : .light)
} detail: { .overlay(alignment: .top) {
MacDetail(vm: vm) if showsUpdateAccessory {
} UpdateBanner(vm: vm)
.navigationSplitViewStyle(.balanced) .padding(16)
.tint(vm.palette.tint) }
.preferredColorScheme(vm.mode == .dark ? .dark : .light)
.overlay(alignment: .top) {
if showsUpdateAccessory {
UpdateBanner(vm: vm)
.padding(12)
} }
} .onAppear { vm.checkUpdate(false) }
.onAppear { vm.checkUpdate(false) } .frame(minWidth: 880, minHeight: 540)
.frame(minWidth: 880, minHeight: 540)
} }
private var showsUpdateAccessory: Bool { private var showsUpdateAccessory: Bool {
vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged" vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged"
} }
}
private struct MacDetail: View { private var tabs: some View {
@ObservedObject var vm: AppModel TabView(selection: $vm.tab) {
Tab(vm.tr("search_home"), systemImage: "square.grid.2x2.fill", value: AppTab.practice) {
var body: some View {
Group {
switch vm.tab {
case .practice:
NavigationStack(path: $vm.practicePath) { NavigationStack(path: $vm.practicePath) {
PracticeHome(vm: vm) PracticeHome(vm: vm)
.navigationDestination(for: Route.self) { route in .navigationDestination(for: Route.self) { route in
RouteDestination(vm: vm, route: route) RouteDestination(vm: vm, route: route)
} }
} }
case .progress: }
Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: AppTab.progress) {
NavigationStack { NavigationStack {
StatsScreen(vm: vm) StatsScreen(vm: vm)
} }
case .search: }
NavigationStack { Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: AppTab.settings) {
SearchScreen(vm: vm)
}
case .settings:
NavigationStack { NavigationStack {
SettingsScreen(vm: vm) SettingsScreen(vm: vm)
} }
} }
Tab(vm.tr("search"), systemImage: "magnifyingglass", value: AppTab.search, role: .search) {
NavigationStack {
SearchScreen(vm: vm)
}
}
} }
.tabViewStyle(.sidebarAdaptable)
.toolbar { .toolbar {
MacWindowToolbar(vm: vm) MacWindowToolbar(vm: vm)
} }
.toolbarBackgroundVisibility(.visible, for: .windowToolbar)
} }
} }
@ -122,7 +116,11 @@ private struct MacWindowToolbar: ToolbarContent {
Label(vm.tr("stats"), systemImage: "chart.bar.fill") Label(vm.tr("stats"), systemImage: "chart.bar.fill")
} }
.help(vm.tr("stats")) .help(vm.tr("stats"))
}
ToolbarSpacer(.fixed, placement: .primaryAction)
ToolbarItem(placement: .primaryAction) {
Button { Button {
vm.tab = .settings vm.tab = .settings
} label: { } label: {
@ -133,58 +131,6 @@ private struct MacWindowToolbar: ToolbarContent {
} }
} }
private struct MacSidebar: View {
@ObservedObject var vm: AppModel
var body: some View {
List(selection: $vm.tab) {
Section {
Label(vm.tr("search_home"), systemImage: "square.grid.2x2.fill")
.tag(AppTab.practice)
Label(vm.tr("stats"), systemImage: "chart.bar.fill")
.tag(AppTab.progress)
Label(vm.tr("search_placeholder"), systemImage: "magnifyingglass")
.tag(AppTab.search)
Label(vm.tr("settings"), systemImage: "gearshape.fill")
.tag(AppTab.settings)
}
Section(vm.tr("subjects_title")) {
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
let open = s.bool("open")
let dest = s.strOrNull("target").flatMap(routeFromPage)
if open, let dest, dest != .subjects, dest != .welcome {
Button {
vm.openFromSearch(dest.page)
} label: {
subjectLabel(s)
}
.buttonStyle(.plain)
} else {
subjectLabel(s)
.foregroundStyle(.secondary)
}
}
}
}
.listStyle(.sidebar)
.navigationTitle("maturita.c")
}
private func subjectLabel(_ s: J) -> some View {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(vm.tr(s.str("key")))
if !s.bool("open") {
Text(vm.tr("stats_locked")).font(.caption).foregroundStyle(.secondary)
}
}
} icon: {
SubjectIcon(icon: s.str("icon"), open: s.bool("open"))
}
}
}
private struct UpdateBanner: View { private struct UpdateBanner: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
@ -196,13 +142,14 @@ private struct UpdateBanner: View {
Spacer() Spacer()
if vm.update.canInstall { if vm.update.canInstall {
Button(vm.tr("update_install")) { vm.installUpdate() } Button(vm.tr("update_install")) { vm.installUpdate() }
.buttonStyle(.borderedProminent) .buttonStyle(.glassProminent)
.controlSize(.small)
} }
} }
.padding(12) .padding(.horizontal, 16)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .padding(.vertical, 12)
.shadow(color: .black.opacity(0.12), radius: 8, y: 2) .glassEffect(.regular, in: .rect(cornerRadius: 18, style: .continuous))
.frame(maxWidth: 640) .frame(maxWidth: 560)
} }
private var bannerText: String { private var bannerText: String {