259 lines
7.9 KiB
Swift
259 lines
7.9 KiB
Swift
import SwiftUI
|
|
#if os(macOS)
|
|
import AppKit
|
|
#elseif canImport(UIKit)
|
|
import UIKit
|
|
#endif
|
|
|
|
private extension Color {
|
|
static func adaptive(
|
|
light: (red: Double, green: Double, blue: Double, opacity: Double),
|
|
dark: (red: Double, green: Double, blue: Double, opacity: Double)
|
|
) -> Color {
|
|
#if os(macOS)
|
|
Color(
|
|
nsColor: NSColor(name: nil) { appearance in
|
|
let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight])
|
|
let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light
|
|
return NSColor(
|
|
red: components.red,
|
|
green: components.green,
|
|
blue: components.blue,
|
|
alpha: components.opacity
|
|
)
|
|
}
|
|
)
|
|
#elseif canImport(UIKit) && !os(watchOS)
|
|
Color(
|
|
uiColor: UIColor { traits in
|
|
let components = traits.userInterfaceStyle == .dark ? dark : light
|
|
return UIColor(
|
|
red: components.red,
|
|
green: components.green,
|
|
blue: components.blue,
|
|
alpha: components.opacity
|
|
)
|
|
}
|
|
)
|
|
#elseif os(watchOS)
|
|
Color(
|
|
red: dark.red,
|
|
green: dark.green,
|
|
blue: dark.blue,
|
|
opacity: dark.opacity
|
|
)
|
|
#else
|
|
Color(
|
|
red: light.red,
|
|
green: light.green,
|
|
blue: light.blue,
|
|
opacity: light.opacity
|
|
)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
enum AppTheme {
|
|
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
|
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
|
|
static let border = Color.adaptive(
|
|
light: (0.00, 0.00, 0.00, 0.08),
|
|
dark: (1.00, 1.00, 1.00, 0.12)
|
|
)
|
|
static let shadow = Color.adaptive(
|
|
light: (0.00, 0.00, 0.00, 0.05),
|
|
dark: (0.00, 0.00, 0.00, 0.32)
|
|
)
|
|
static let cardFill = Color.adaptive(
|
|
light: (1.00, 1.00, 1.00, 0.96),
|
|
dark: (0.11, 0.12, 0.14, 0.96)
|
|
)
|
|
static let mutedFill = Color.adaptive(
|
|
light: (0.972, 0.976, 0.970, 1.00),
|
|
dark: (0.16, 0.17, 0.19, 1.00)
|
|
)
|
|
static let backgroundTop = Color.adaptive(
|
|
light: (0.975, 0.978, 0.972, 1.00),
|
|
dark: (0.08, 0.09, 0.10, 1.00)
|
|
)
|
|
static let backgroundBottom = Color.adaptive(
|
|
light: (1.00, 1.00, 1.00, 1.00),
|
|
dark: (0.05, 0.06, 0.07, 1.00)
|
|
)
|
|
static let backgroundGlow = Color.adaptive(
|
|
light: (0.00, 0.00, 0.00, 0.02),
|
|
dark: (1.00, 1.00, 1.00, 0.06)
|
|
)
|
|
static let chromeFill = Color.adaptive(
|
|
light: (1.00, 1.00, 1.00, 0.98),
|
|
dark: (0.10, 0.11, 0.13, 0.98)
|
|
)
|
|
}
|
|
|
|
enum AppLayout {
|
|
static let compactHorizontalPadding: CGFloat = 16
|
|
static let regularHorizontalPadding: CGFloat = 28
|
|
static let compactVerticalPadding: CGFloat = 18
|
|
static let regularVerticalPadding: CGFloat = 28
|
|
static let compactContentWidth: CGFloat = 720
|
|
static let regularContentWidth: CGFloat = 920
|
|
static let cardRadius: CGFloat = 24
|
|
static let largeCardRadius: CGFloat = 30
|
|
static let compactSectionPadding: CGFloat = 18
|
|
static let regularSectionPadding: CGFloat = 24
|
|
static let compactSectionSpacing: CGFloat = 18
|
|
static let regularSectionSpacing: CGFloat = 24
|
|
static let compactBottomDockPadding: CGFloat = 120
|
|
static let regularBottomPadding: CGFloat = 56
|
|
|
|
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
|
|
}
|
|
|
|
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactVerticalPadding : regularVerticalPadding
|
|
}
|
|
|
|
static func contentWidth(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactContentWidth : regularContentWidth
|
|
}
|
|
|
|
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactSectionPadding : regularSectionPadding
|
|
}
|
|
|
|
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
|
|
compactLayout ? compactSectionSpacing : regularSectionSpacing
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
|
|
background(
|
|
fill,
|
|
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
.stroke(AppTheme.border, lineWidth: 1)
|
|
)
|
|
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
|
|
}
|
|
}
|
|
|
|
struct AppBackground: View {
|
|
var body: some View {
|
|
LinearGradient(
|
|
colors: [
|
|
AppTheme.backgroundTop,
|
|
AppTheme.backgroundBottom
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.overlay(alignment: .top) {
|
|
Rectangle()
|
|
.fill(AppTheme.backgroundGlow)
|
|
.frame(height: 160)
|
|
.blur(radius: 60)
|
|
.offset(y: -90)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
|
|
struct AppScrollScreen<Content: View>: View {
|
|
let compactLayout: Bool
|
|
var bottomPadding: CGFloat? = nil
|
|
let content: () -> Content
|
|
|
|
init(
|
|
compactLayout: Bool,
|
|
bottomPadding: CGFloat? = nil,
|
|
@ViewBuilder content: @escaping () -> Content
|
|
) {
|
|
self.compactLayout = compactLayout
|
|
self.bottomPadding = bottomPadding
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
|
content()
|
|
}
|
|
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
|
|
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
|
|
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
|
|
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
|
|
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
}
|
|
}
|
|
|
|
struct AppPanel<Content: View>: View {
|
|
let compactLayout: Bool
|
|
let radius: CGFloat
|
|
let content: () -> Content
|
|
|
|
init(
|
|
compactLayout: Bool,
|
|
radius: CGFloat = AppLayout.cardRadius,
|
|
@ViewBuilder content: @escaping () -> Content
|
|
) {
|
|
self.compactLayout = compactLayout
|
|
self.radius = radius
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
content()
|
|
}
|
|
.padding(AppLayout.sectionPadding(for: compactLayout))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.appSurface(radius: radius)
|
|
}
|
|
}
|
|
|
|
struct AppBadge: View {
|
|
let title: String
|
|
var tone: Color = AppTheme.accent
|
|
|
|
var body: some View {
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(tone)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(tone.opacity(0.10), in: Capsule())
|
|
}
|
|
}
|
|
|
|
struct AppSectionCard<Content: View>: View {
|
|
let title: String
|
|
var subtitle: String? = nil
|
|
let compactLayout: Bool
|
|
let content: () -> Content
|
|
|
|
init(
|
|
title: String,
|
|
subtitle: String? = nil,
|
|
compactLayout: Bool,
|
|
@ViewBuilder content: @escaping () -> Content
|
|
) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.compactLayout = compactLayout
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
AppPanel(compactLayout: compactLayout) {
|
|
AppSectionTitle(title: title, subtitle: subtitle)
|
|
content()
|
|
}
|
|
}
|
|
}
|