332 lines
12 KiB
Swift
332 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
/// User-overridable appearance setting that lives independently of the system
|
|
/// preference, mirroring the prototype's titlebar light/dark toggle.
|
|
enum AppearanceMode: String, CaseIterable, Identifiable {
|
|
case system, light, dark
|
|
var id: String { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .system: return "System"
|
|
case .light: return "Light"
|
|
case .dark: return "Dark"
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .system: return "circle.lefthalf.filled"
|
|
case .light: return "sun.max.fill"
|
|
case .dark: return "moon.fill"
|
|
}
|
|
}
|
|
|
|
var colorScheme: ColorScheme? {
|
|
switch self {
|
|
case .system: return nil
|
|
case .light: return .light
|
|
case .dark: return .dark
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ThemeManager: ObservableObject {
|
|
static let shared = ThemeManager()
|
|
|
|
@AppStorage("PureMac.Appearance") private var rawValue: String = AppearanceMode.system.rawValue
|
|
|
|
var appearance: AppearanceMode {
|
|
get { AppearanceMode(rawValue: rawValue) ?? .system }
|
|
set { rawValue = newValue.rawValue; objectWillChange.send() }
|
|
}
|
|
}
|
|
|
|
/// Centralized accent palette. One blue, one green for success, one orange
|
|
/// for warning, one red for destructive. Other tints exist for categorical
|
|
/// differentiation but the surface chrome only uses these four.
|
|
enum Tint {
|
|
static let blue = Color(red: 0.04, green: 0.52, blue: 1.00)
|
|
static let green = Color(red: 0.18, green: 0.78, blue: 0.47)
|
|
static let orange = Color(red: 1.00, green: 0.58, blue: 0.04)
|
|
static let purple = Color(red: 0.55, green: 0.32, blue: 0.87)
|
|
static let pink = Color(red: 1.00, green: 0.30, blue: 0.50)
|
|
static let cyan = Color(red: 0.30, green: 0.78, blue: 0.95)
|
|
static let red = Color(red: 1.00, green: 0.27, blue: 0.23)
|
|
static let yellow = Color(red: 1.00, green: 0.78, blue: 0.04)
|
|
}
|
|
|
|
/// Shared animation vocabulary so every surface moves with the same feel.
|
|
/// Hover/selection feedback uses `snappy`, entrances and state swaps use
|
|
/// `gentle`, press acknowledgment uses `press`.
|
|
enum MotionTokens {
|
|
static let snappy = Animation.spring(response: 0.3, dampingFraction: 0.7)
|
|
static let gentle = Animation.spring(response: 0.5, dampingFraction: 0.85)
|
|
static let press = Animation.easeOut(duration: 0.12)
|
|
}
|
|
|
|
/// Gradient pairs built from the flat `Tint` palette. Reserved for primary
|
|
/// CTAs and focal chrome — secondary surfaces stay flat.
|
|
enum TintGradient {
|
|
static let accent = LinearGradient(
|
|
colors: [Tint.blue, Tint.purple],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
)
|
|
static let destructive = LinearGradient(
|
|
colors: [Tint.red, Tint.red.opacity(0.8)],
|
|
startPoint: .top, endPoint: .bottom
|
|
)
|
|
static func of(_ color: Color) -> LinearGradient {
|
|
LinearGradient(colors: [color, color.opacity(0.65)], startPoint: .top, endPoint: .bottom)
|
|
}
|
|
}
|
|
|
|
/// Tinted square icon container used in the sidebar and on dashboard cards.
|
|
/// Single muted fill, thin border. When `glow` is set (selected sidebar row,
|
|
/// emphasized card) the tile picks up a gradient fill and a soft tinted halo.
|
|
struct IconTile: View {
|
|
let systemName: String
|
|
var tint: Color = Tint.blue
|
|
var size: CGFloat = 26
|
|
var corner: CGFloat = 7
|
|
var glow: Bool = false
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: corner, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [tint.opacity(glow ? 0.30 : 0.14), tint.opacity(0.14)],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.shadow(color: tint.opacity(glow ? 0.45 : 0), radius: glow ? 5 : 0)
|
|
Image(systemName: systemName)
|
|
.font(.system(size: size * 0.52, weight: .semibold))
|
|
.foregroundStyle(tint)
|
|
.shadow(color: tint.opacity(glow ? 0.5 : 0), radius: glow ? 3 : 0)
|
|
}
|
|
.frame(width: size, height: size)
|
|
.animation(reduceMotion ? nil : MotionTokens.snappy, value: glow)
|
|
}
|
|
}
|
|
|
|
/// Card surface. Flat fill, hairline border, single soft shadow. No accent
|
|
/// stripe by default — content hierarchy carries the meaning, not chrome.
|
|
/// Pass `material` for a vibrancy/glass panel (used on focal hero states
|
|
/// where a tinted backdrop sits behind the card).
|
|
struct CardSurface<Content: View>: View {
|
|
var padding: CGFloat = 16
|
|
/// Retained for callsite compatibility; the accent line is intentionally
|
|
/// not rendered in the restrained design.
|
|
var accent: Color? = nil
|
|
var elevation: CardElevation = .standard
|
|
var material: Material? = nil
|
|
@ViewBuilder var content: Content
|
|
|
|
var body: some View {
|
|
content
|
|
.padding(padding)
|
|
.background(
|
|
ZStack {
|
|
if let material {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(material)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color(nsColor: .controlBackgroundColor))
|
|
}
|
|
}
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(
|
|
material != nil ? Color.white.opacity(0.14) : Color.primary.opacity(0.07),
|
|
lineWidth: material != nil ? 1 : 0.5
|
|
)
|
|
)
|
|
.shadow(color: .black.opacity(elevation.ambient), radius: elevation.ambientRadius, y: elevation.ambientY)
|
|
}
|
|
}
|
|
|
|
enum CardElevation {
|
|
case flat, standard, raised
|
|
|
|
var ambient: Double {
|
|
switch self {
|
|
case .flat: return 0.0
|
|
case .standard: return 0.04
|
|
case .raised: return 0.07
|
|
}
|
|
}
|
|
|
|
var ambientRadius: CGFloat {
|
|
switch self {
|
|
case .flat: return 0
|
|
case .standard: return 4
|
|
case .raised: return 10
|
|
}
|
|
}
|
|
|
|
var ambientY: CGFloat {
|
|
switch self {
|
|
case .flat: return 0
|
|
case .standard: return 1
|
|
case .raised: return 3
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Small status pill. Solid tint background at low opacity, no gradient.
|
|
struct StatusChip: View {
|
|
let label: String
|
|
var systemImage: String? = nil
|
|
var tint: Color = Tint.blue
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
if let systemImage {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 9, weight: .bold))
|
|
}
|
|
Text(label)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.monospacedDigit()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(Capsule().fill(tint.opacity(0.14)))
|
|
.foregroundStyle(tint)
|
|
}
|
|
}
|
|
|
|
/// Hover/press feedback for tappable cards. Plain mode is a subtle scale;
|
|
/// `lift` mode adds the CleanMyMac-style float (rise + soft shadow). Under
|
|
/// Reduce Motion the scale/offset are dropped and only the shadow remains.
|
|
struct PressableScale: ViewModifier {
|
|
@State private var hovering = false
|
|
@State private var pressing = false
|
|
var hoverScale: CGFloat = 1.006
|
|
var lift: Bool = false
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.scaleEffect(reduceMotion ? 1.0 : (pressing ? 0.97 : (hovering ? (lift ? 1.02 : hoverScale) : 1.0)))
|
|
.offset(y: lift && hovering && !reduceMotion ? -2 : 0)
|
|
.shadow(color: .black.opacity(lift && hovering ? 0.12 : 0),
|
|
radius: lift && hovering ? 14 : 0,
|
|
y: lift && hovering ? 6 : 0)
|
|
.animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering)
|
|
.animation(reduceMotion ? nil : MotionTokens.press, value: pressing)
|
|
.onHover { hovering = $0 }
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { _ in pressing = true }
|
|
.onEnded { _ in pressing = false }
|
|
)
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func pressable(hoverScale: CGFloat = 1.006, lift: Bool = false) -> some View {
|
|
modifier(PressableScale(hoverScale: hoverScale, lift: lift))
|
|
}
|
|
}
|
|
|
|
/// Gradient capsule CTA with a soft tinted glow — the primary-action style.
|
|
/// Hover lifts the glow and scale; press squeezes. `breathes` adds a gentle
|
|
/// idle glow pulse (radius only, no scale) for the single hero CTA on an
|
|
/// otherwise calm screen. All motion is suppressed under Reduce Motion.
|
|
struct GlowProminentButtonStyle: ButtonStyle {
|
|
var tint: Color = Tint.blue
|
|
var gradient: LinearGradient = TintGradient.accent
|
|
var breathes: Bool = false
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
GlowBody(configuration: configuration, tint: tint, gradient: gradient, breathes: breathes)
|
|
}
|
|
|
|
private struct GlowBody: View {
|
|
let configuration: ButtonStyleConfiguration
|
|
let tint: Color
|
|
let gradient: LinearGradient
|
|
let breathes: Bool
|
|
|
|
@State private var hovering = false
|
|
@State private var breathe = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
configuration.label
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 9)
|
|
.background(Capsule().fill(gradient))
|
|
.overlay(Capsule().strokeBorder(.white.opacity(0.18), lineWidth: 0.5))
|
|
.shadow(color: tint.opacity(hovering ? 0.45 : (breathe ? 0.40 : 0.22)),
|
|
radius: hovering ? 14 : (breathe ? 12 : 7), y: 3)
|
|
.scaleEffect(reduceMotion ? 1 : (configuration.isPressed ? 0.97 : (hovering ? 1.03 : 1)))
|
|
.animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering)
|
|
.animation(reduceMotion ? nil : MotionTokens.press, value: configuration.isPressed)
|
|
.onHover { hovering = $0 }
|
|
.onAppear {
|
|
guard breathes, !reduceMotion else { return }
|
|
withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) {
|
|
breathe = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Checkbox replacement with a springy check pop. Visually matches the size
|
|
/// of the native control so adopting it doesn't shift row layout. Keeps the
|
|
/// native checkbox's accessibility semantics (role, checked value, Space-key
|
|
/// toggling, VoiceOver) via accessibilityRepresentation so swapping it in
|
|
/// doesn't regress keyboard/screen-reader users.
|
|
struct AnimatedCheckboxStyle: ToggleStyle {
|
|
var tint: Color = Tint.blue
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
CheckBody(configuration: configuration, tint: tint)
|
|
.accessibilityRepresentation {
|
|
Toggle(isOn: configuration.$isOn) { configuration.label }
|
|
.toggleStyle(.checkbox)
|
|
}
|
|
}
|
|
|
|
private struct CheckBody: View {
|
|
let configuration: ToggleStyleConfiguration
|
|
let tint: Color
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 4.5, style: .continuous)
|
|
.fill(configuration.isOn ? tint : Color.primary.opacity(0.05))
|
|
RoundedRectangle(cornerRadius: 4.5, style: .continuous)
|
|
.strokeBorder(configuration.isOn ? tint : Color.primary.opacity(0.25), lineWidth: 1)
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.scaleEffect(configuration.isOn ? 1 : 0.3)
|
|
.opacity(configuration.isOn ? 1 : 0)
|
|
}
|
|
.frame(width: 15, height: 15)
|
|
.animation(reduceMotion ? nil : .spring(response: 0.25, dampingFraction: 0.6), value: configuration.isOn)
|
|
|
|
configuration.label
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { configuration.isOn.toggle() }
|
|
}
|
|
}
|
|
}
|