Some checks failed
CI / test (push) Has been cancelled
Move the app payload under swift/ while keeping git, package.json, and .smartconfig.json at the repo root. This standardizes the Swift app setup so build, test, run, and watch workflows match the other repos.
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()
|
|
}
|
|
}
|
|
}
|