From 1569df7e13b3f78eee33d1f454acdf7f208286c3 Mon Sep 17 00:00:00 2001 From: pepaskopec-blip Date: Mon, 21 Sep 2026 12:39:06 +0200 Subject: [PATCH] Give the Mac app Apple's Liquid Glass chrome with a system sidebar and glass cards. Co-authored-by: Cursor --- ios/Maturita/UI/Components.swift | 45 ++++++++-- ios/Maturita/UI/Exercises.swift | 4 +- ios/Maturita/UI/ExercisesMore.swift | 8 +- ios/Maturita/UI/Screens.swift | 134 ++++++++++++++++++++++++---- macos/Maturita/MaturitaApp.swift | 123 ++++++++----------------- 5 files changed, 193 insertions(+), 121 deletions(-) diff --git a/ios/Maturita/UI/Components.swift b/ios/Maturita/UI/Components.swift index 592cca6..cf038a2 100644 --- a/ios/Maturita/UI/Components.swift +++ b/ios/Maturita/UI/Components.swift @@ -49,12 +49,12 @@ struct PrimaryButton: View { .font(.body.weight(.semibold)) .frame(maxWidth: .infinity) } - #if os(iOS) .buttonStyle(.glassProminent) + #if os(macOS) + .controlSize(.extraLarge) #else - .buttonStyle(.borderedProminent) - #endif .controlSize(.large) + #endif } } @@ -75,7 +75,7 @@ struct GlassCard: View { content() .padding(16) .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() #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(@ViewBuilder _ bar: () -> Bar) -> some View { + safeAreaBar(edge: .bottom, spacing: 8, content: bar) + } } /// Bottom action area used by exercises and lesson slides. @@ -304,7 +331,6 @@ struct BottomAction: View { .padding(.horizontal) .padding(.top, 8) .padding(.bottom, 4) - .background(.bar) } } @@ -322,19 +348,20 @@ func subjectSymbol(_ icon: String) -> String { struct SubjectIcon: View { let icon: String var open = true + var size: CGFloat = 26 var body: some View { Group { switch icon { - case "de": GermanFlag(size: 26) - case "cz": CzechFlag(size: 26) + case "de": GermanFlag(size: size) + case "cz": CzechFlag(size: size) default: Image(systemName: subjectSymbol(icon)) - .font(.title3) + .font(.system(size: size * 0.72, weight: .semibold)) .foregroundStyle(open ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary)) } } - .frame(width: 28, height: 28) + .frame(width: size, height: size) .opacity(open ? 1 : 0.6) } } diff --git a/ios/Maturita/UI/Exercises.swift b/ios/Maturita/UI/Exercises.swift index 0c68e1b..93a752a 100644 --- a/ios/Maturita/UI/Exercises.swift +++ b/ios/Maturita/UI/Exercises.swift @@ -18,7 +18,7 @@ struct ExerciseScaffold: View { } .scrollDismissesKeyboard(.interactively) .detailScreen(title, subtitle: sub) - .safeAreaInset(edge: .bottom) { + .glassBottomBar { BottomAction(label: action, action: onAction) { if !feedback.isEmpty { FeedbackLine(text: feedback, kind: kind, palette: p) @@ -601,7 +601,7 @@ private struct VocabEx: View { } .scrollDismissesKeyboard(.interactively) .detailScreen(title, subtitle: sub) - .safeAreaInset(edge: .bottom) { + .glassBottomBar { if index < cards.count { BottomAction(label: vm.tr("check"), action: { check(cards[index]) }) { EmptyView() } } diff --git a/ios/Maturita/UI/ExercisesMore.swift b/ios/Maturita/UI/ExercisesMore.swift index 23e637d..5934373 100644 --- a/ios/Maturita/UI/ExercisesMore.swift +++ b/ios/Maturita/UI/ExercisesMore.swift @@ -741,12 +741,12 @@ private struct VlsmEx: View { } .scrollDismissesKeyboard(.interactively) .detailScreen(vm.tr("net_ex_title")) - .safeAreaInset(edge: .bottom) { + .glassBottomBar { if !fb.0.isEmpty { FeedbackLine(text: fb.0, kind: fb.1, palette: p) - .padding() + .padding(.horizontal) + .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background(.bar) } } .animation(.default, value: fb.0) @@ -842,7 +842,7 @@ struct LitQuizScreen: View { .exerciseContent() } .detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub"))) - .safeAreaInset(edge: .bottom) { + .glassBottomBar { if finished { BottomAction(label: vm.tr("lit_again"), action: { idx = 0; score = 0; answered = false; finished = false; picked = -1 diff --git a/ios/Maturita/UI/Screens.swift b/ios/Maturita/UI/Screens.swift index 72b16c5..e296667 100644 --- a/ios/Maturita/UI/Screens.swift +++ b/ios/Maturita/UI/Screens.swift @@ -4,6 +4,100 @@ struct PracticeHome: View { @ObservedObject var vm: AppModel 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 pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx) List { @@ -50,17 +144,7 @@ struct PracticeHome: View { } } .navigationTitle("Maturita") - #if os(iOS) .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 { @@ -75,6 +159,15 @@ struct PracticeHome: View { 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 { @@ -99,7 +192,7 @@ struct ChangelogCard: View { ChangelogLines(index: index, lines: changelogLines(block, langKey)) } Button(vm.tr("changelog_dismiss")) { vm.dismissChangelog() } - .buttonStyle(.borderedProminent) + .buttonStyle(.glassProminent) .controlSize(.small) } } @@ -171,6 +264,7 @@ struct SettingsScreen: View { } } .navigationTitle(vm.tr("settings_title")) + .appFormStyle() } private var modeBinding: Binding { @@ -237,6 +331,7 @@ struct StatsScreen: View { } } .navigationTitle(vm.tr("stats_title")) + .appListStyle() } 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")) + .appListStyle() } } @@ -372,6 +468,7 @@ struct RoadmapScreen: View { } .navigationTitle(vm.tr("roadmap_title")) .navigationSubtitle(vm.tr("roadmap_sub")) + .appListStyle() } } @@ -400,6 +497,7 @@ struct UnitMapScreen: View { } .navigationTitle(u.str("title")) .navigationSubtitle(vm.tr(u.str("sub"))) + .appListStyle() } } } @@ -429,6 +527,7 @@ struct NetYearsScreen: View { } .navigationTitle(vm.tr("net_years_title")) .navigationSubtitle(vm.tr("net_years_sub")) + .appListStyle() } } @@ -456,6 +555,7 @@ struct LessonListScreen: View { } .navigationTitle(title) .navigationSubtitle(subtitle) + .appListStyle() } } @@ -483,7 +583,7 @@ struct SlidesScreen: View { #endif } .detailScreen(title, subtitle: subtitle) - .safeAreaInset(edge: .bottom) { + .glassBottomBar { VStack(spacing: 10) { HStack(spacing: 6) { ForEach(slides.indices, id: \.self) { i in @@ -501,11 +601,7 @@ struct SlidesScreen: View { Image(systemName: "chevron.left") .frame(minWidth: 24) } - #if os(iOS) .buttonStyle(.glass) - #else - .buttonStyle(.bordered) - #endif .controlSize(.large) } if idx < slides.count - 1 { @@ -522,7 +618,6 @@ struct SlidesScreen: View { .padding(.horizontal) .padding(.top, 8) .padding(.bottom, 4) - .background(.bar) } } @@ -568,6 +663,7 @@ struct CzechMapScreen: View { } .navigationTitle(vm.tr("Český jazyk a literatura")) .navigationSubtitle(vm.tr("czech_sub")) + .appListStyle() } } @@ -591,6 +687,7 @@ struct BookListScreen: View { } .navigationTitle(vm.tr("Maturitní četba")) .navigationSubtitle(vm.tr("reading_sub")) + .appListStyle() } } @@ -631,6 +728,7 @@ struct BookScreen: View { } .navigationTitle(b.str("title")) .navigationSubtitle(vm.tr(b.str("subKey"))) + .appListStyle() } } } diff --git a/macos/Maturita/MaturitaApp.swift b/macos/Maturita/MaturitaApp.swift index c84259a..dc64c07 100644 --- a/macos/Maturita/MaturitaApp.swift +++ b/macos/Maturita/MaturitaApp.swift @@ -10,6 +10,7 @@ struct MaturitaApp: App { } .defaultSize(width: 1100, height: 720) .windowToolbarStyle(.unified) + .windowStyle(.automatic) .commands { CommandGroup(replacing: .newItem) {} CommandGroup(after: .sidebar) { @@ -35,6 +36,7 @@ struct MaturitaApp: App { Settings { SettingsScreen(vm: vm) + .formStyle(.grouped) .frame(minWidth: 420, minHeight: 460) .tint(vm.palette.tint) .preferredColorScheme(vm.mode == .dark ? .dark : .light) @@ -46,61 +48,53 @@ struct MacRoot: View { @ObservedObject var vm: AppModel var body: some View { - NavigationSplitView { - MacSidebar(vm: vm) - .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 320) - } detail: { - MacDetail(vm: vm) - } - .navigationSplitViewStyle(.balanced) - .tint(vm.palette.tint) - .preferredColorScheme(vm.mode == .dark ? .dark : .light) - .overlay(alignment: .top) { - if showsUpdateAccessory { - UpdateBanner(vm: vm) - .padding(12) + tabs + .tint(vm.palette.tint) + .preferredColorScheme(vm.mode == .dark ? .dark : .light) + .overlay(alignment: .top) { + if showsUpdateAccessory { + UpdateBanner(vm: vm) + .padding(16) + } } - } - .onAppear { vm.checkUpdate(false) } - .frame(minWidth: 880, minHeight: 540) + .onAppear { vm.checkUpdate(false) } + .frame(minWidth: 880, minHeight: 540) } private var showsUpdateAccessory: Bool { vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged" } -} -private struct MacDetail: View { - @ObservedObject var vm: AppModel - - var body: some View { - Group { - switch vm.tab { - case .practice: + private var tabs: some View { + TabView(selection: $vm.tab) { + Tab(vm.tr("search_home"), systemImage: "square.grid.2x2.fill", value: AppTab.practice) { NavigationStack(path: $vm.practicePath) { PracticeHome(vm: vm) .navigationDestination(for: Route.self) { route in RouteDestination(vm: vm, route: route) } } - case .progress: + } + Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: AppTab.progress) { NavigationStack { StatsScreen(vm: vm) } - case .search: - NavigationStack { - SearchScreen(vm: vm) - } - case .settings: + } + Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: AppTab.settings) { NavigationStack { SettingsScreen(vm: vm) } } + Tab(vm.tr("search"), systemImage: "magnifyingglass", value: AppTab.search, role: .search) { + NavigationStack { + SearchScreen(vm: vm) + } + } } + .tabViewStyle(.sidebarAdaptable) .toolbar { MacWindowToolbar(vm: vm) } - .toolbarBackgroundVisibility(.visible, for: .windowToolbar) } } @@ -122,7 +116,11 @@ private struct MacWindowToolbar: ToolbarContent { Label(vm.tr("stats"), systemImage: "chart.bar.fill") } .help(vm.tr("stats")) + } + ToolbarSpacer(.fixed, placement: .primaryAction) + + ToolbarItem(placement: .primaryAction) { Button { vm.tab = .settings } 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 { @ObservedObject var vm: AppModel @@ -196,13 +142,14 @@ private struct UpdateBanner: View { Spacer() if vm.update.canInstall { Button(vm.tr("update_install")) { vm.installUpdate() } - .buttonStyle(.borderedProminent) + .buttonStyle(.glassProminent) + .controlSize(.small) } } - .padding(12) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 8, y: 2) - .frame(maxWidth: 640) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .glassEffect(.regular, in: .rect(cornerRadius: 18, style: .continuous)) + .frame(maxWidth: 560) } private var bannerText: String {