Graduately/ios/Maturita/UI/Components.swift
pepaskopec-blip 1569df7e13 Give the Mac app Apple's Liquid Glass chrome with a system sidebar and glass cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 12:39:06 +02:00

367 lines
10 KiB
Swift

import SwiftUI
struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
arrange(width: proposal.width ?? .infinity, subviews: subviews).size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrange(width: bounds.width, subviews: subviews)
for (i, frame) in result.frames.enumerated() {
subviews[i].place(
at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY),
proposal: ProposedViewSize(frame.size)
)
}
}
private func arrange(width: CGFloat, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) {
var frames: [CGRect] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowH: CGFloat = 0
var maxW: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x > 0 && x + size.width > width {
x = 0
y += rowH + spacing
rowH = 0
}
frames.append(CGRect(origin: CGPoint(x: x, y: y), size: size))
x += size.width + spacing
rowH = max(rowH, size.height)
maxW = max(maxW, x - spacing)
}
return (CGSize(width: max(maxW, 0), height: y + rowH), frames)
}
}
struct PrimaryButton: View {
let label: String
var action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
}
.buttonStyle(.glassProminent)
#if os(macOS)
.controlSize(.extraLarge)
#else
.controlSize(.large)
#endif
}
}
struct PillButton: View {
let label: String
var action: () -> Void
var body: some View {
Button(label, action: action)
.buttonStyle(.bordered)
}
}
struct GlassCard<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
content()
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassEffect(.regular, in: .rect(cornerRadius: 26, style: .continuous))
}
}
struct FeedbackLine: View {
let text: String
let kind: String
let palette: Palette
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")
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
}
}
}
struct Meaning: View {
let text: String
let palette: Palette
let visible: Bool
var body: some View {
if visible && !text.isEmpty {
Text(text)
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
}
}
}
struct WordField: View {
@Binding var value: String
var placeholder: String
var mark: Bool?
var palette: Palette?
var body: some View {
HStack(spacing: 8) {
TextField(placeholder, text: $value)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if let mark {
Image(systemName: mark ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(mark ? (palette?.success ?? .green) : (palette?.error ?? .red))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(border, lineWidth: mark == nil ? 0.5 : 1.5)
)
}
private var border: Color {
if mark == true { return palette?.success ?? .green }
if mark == false { return palette?.error ?? .red }
return Color(.separator)
}
}
struct SegChip: View {
let label: String
let selected: Bool
var action: () -> Void
var body: some View {
Button(label, action: action)
.buttonStyle(.bordered)
.tint(selected ? .accentColor : .secondary)
}
}
struct Hint: View {
let text: String
var body: some View {
Text(text).font(.footnote).foregroundStyle(.secondary).padding(.bottom, 6)
}
}
struct Prompt: View {
let text: String
var body: some View {
Text(text).font(.body.weight(.medium))
}
}
struct WordChip: View {
let text: String
var action: () -> Void
var body: some View {
Button(text, action: action)
.buttonStyle(.bordered)
.tint(.primary)
.controlSize(.small)
}
}
/// Drop zone for word ordering: visible even when empty.
struct WordSlot<Content: View>: View {
let empty: Bool
@ViewBuilder var content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(8)
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(style: StrokeStyle(lineWidth: empty ? 1 : 0, dash: [5, 4]))
.foregroundStyle(.tertiary)
)
}
}
struct OptionChip: View {
let text: String
let selected: Bool
let mark: Bool?
var action: () -> Void
var body: some View {
Button(action: action) {
HStack {
Image(systemName: symbol)
.foregroundStyle(symbolColor)
Text(text)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 4)
}
.buttonStyle(.bordered)
.tint(tint)
.padding(.bottom, 4)
}
private var symbol: String {
if mark == true { return "checkmark.circle.fill" }
if mark == false { return "xmark.circle.fill" }
return selected ? "largecircle.fill.circle" : "circle"
}
private var symbolColor: Color {
if mark == true { return .green }
if mark == false { return .red }
return selected ? .accentColor : .secondary
}
private var tint: Color {
if mark == true { return .green }
if mark == false { return .red }
return selected ? .accentColor : .secondary
}
}
extension View {
@ViewBuilder
func navSubtitle(_ text: String?) -> some View {
if let text, !text.isEmpty {
navigationSubtitle(text)
} else {
self
}
}
/// Detail screens with their own bottom action hide the tab bar so the
/// primary button is never covered by it.
@ViewBuilder
func detailScreen(_ title: String, subtitle: String? = nil) -> some View {
#if os(iOS)
navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(subtitle)
.toolbar(.hidden, for: .tabBar)
#else
navigationTitle(title)
.navSubtitle(subtitle)
#endif
}
@ViewBuilder
func sheetDetents() -> some View {
#if os(iOS)
presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
#else
self
#endif
}
/// Full-width, leading-aligned content for exercise screens.
func exerciseContent() -> some View {
#if os(macOS)
frame(maxWidth: 720, alignment: .leading)
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
#else
frame(maxWidth: .infinity, alignment: .leading)
.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<Bar: View>(@ViewBuilder _ bar: () -> Bar) -> some View {
safeAreaBar(edge: .bottom, spacing: 8, content: bar)
}
}
/// Bottom action area used by exercises and lesson slides.
struct BottomAction<Extra: View>: View {
let label: String
var action: () -> Void
@ViewBuilder var extra: () -> Extra
var body: some View {
VStack(spacing: 10) {
extra()
PrimaryButton(label: label, action: action)
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
}
}
func subjectSymbol(_ icon: String) -> String {
switch icon {
case "de": return "character.book.closed.fill"
case "wifi": return "wifi"
case "chip": return "cpu"
case "cz": return "text.book.closed.fill"
default: return "lock.fill"
}
}
/// Subject glyph for list rows: real flags for the languages, SF Symbols otherwise.
struct SubjectIcon: View {
let icon: String
var open = true
var size: CGFloat = 26
var body: some View {
Group {
switch icon {
case "de": GermanFlag(size: size)
case "cz": CzechFlag(size: size)
default:
Image(systemName: subjectSymbol(icon))
.font(.system(size: size * 0.72, weight: .semibold))
.foregroundStyle(open ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary))
}
}
.frame(width: size, height: size)
.opacity(open ? 1 : 0.6)
}
}