Files
haptika/ios/HaptikaWatch/Views/WatchTheme.swift
2026-09-07 16:02:36 +02:00

161 lines
6.0 KiB
Swift

import SwiftUI
import CoreText
enum WatchTheme {
static func font(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
.custom("Inter-Regular", size: size).weight(weight)
}
static func registerFonts() {
guard let url = Bundle.main.url(forResource: "Inter", withExtension: "ttf") else { return }
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
}
static let background = Color(hex: 0x0C141F)
static let night = Color(hex: 0x0A1019)
static let card = Color(hex: 0x151C27)
static let accent = Color("AccentColor")
static let mint = Color(hex: 0x79D3FF)
static let secondary = Color(hex: 0xBEC8CF)
static let text = Color(hex: 0xDCE3F3)
static let purple = Color(hex: 0xA78BFA)
static let pink = Color(hex: 0xE94697)
static let stop = Color(hex: 0xFFB4AB)
}
private extension Color {
init(hex: UInt32) {
self.init(red: Double((hex >> 16) & 255) / 255, green: Double((hex >> 8) & 255) / 255, blue: Double(hex & 255) / 255)
}
}
struct WatchCard<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content.padding(12).frame(maxWidth: .infinity, alignment: .leading)
.background(WatchTheme.card, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.08)))
}
}
struct WatchPillStyle: ButtonStyle {
var primary = false
var destructive = false
var height: CGFloat = 36
var fontSize: CGFloat = 14
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(destructive ? .system(size: fontSize) : WatchTheme.font(fontSize, weight: .semibold))
.foregroundStyle(destructive ? WatchTheme.stop : primary ? Color(red: 0, green: 0.208, blue: 0.282) : WatchTheme.text)
.frame(maxWidth: .infinity).frame(height: height)
.background(primary ? WatchTheme.accent : destructive ? .clear : Color(red: 0.18, green: 0.208, blue: 0.255), in: Capsule())
.overlay(Capsule().stroke(destructive ? WatchTheme.stop.opacity(0.3) : .clear))
.opacity(configuration.isPressed ? 0.65 : 1)
.contentShape(Capsule())
}
}
struct WatchMenuRow: View {
let title: String
var color: Color = WatchTheme.accent
var body: some View {
HStack {
Text(title).font(WatchTheme.font(16, weight: .medium)).fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 4)
Image("WatchChevron").resizable().scaledToFit().frame(width: 5, height: 8).accessibilityHidden(true)
}
.foregroundStyle(WatchTheme.text)
.padding(.horizontal, 15).frame(minHeight: 50)
.background(LinearGradient(colors: [color.opacity(0.1), WatchTheme.card], startPoint: .leading, endPoint: .trailing))
.overlay(alignment: .leading) { Rectangle().fill(color).frame(width: 4) }
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
struct WatchGlow: View {
var color: Color = WatchTheme.accent
var body: some View {
Circle()
.fill(RadialGradient(colors: [color.opacity(0.24), color.opacity(0)], center: .center, startRadius: 0, endRadius: 100))
.frame(width: 210, height: 210)
.allowsHitTesting(false).accessibilityHidden(true)
}
}
struct WatchEyebrow: View {
let title: String
var body: some View {
Text(title.uppercased()).font(WatchTheme.font(12, weight: .semibold))
.tracking(0.6).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
.lineLimit(1).minimumScaleFactor(0.7).fixedSize(horizontal: false, vertical: true)
}
}
func watchTime(_ seconds: TimeInterval) -> String {
let value = max(0, Int(seconds.rounded(.up)))
return String(format: "%02d:%02d", value / 60, value % 60)
}
extension View {
func haptikaScreen() -> some View {
scrollContentBackground(.hidden)
.background(WatchTheme.background.ignoresSafeArea())
.foregroundStyle(WatchTheme.text)
.tint(WatchTheme.accent)
}
}
// Explicit Debug-only presentation fixtures. Never saved or sent to HealthKit.
enum WatchDesignMode {
static var enabled: Bool {
#if DEBUG && targetEnvironment(simulator)
ProcessInfo.processInfo.arguments.contains("--figma-preview")
#else
false
#endif
}
static var initialScreen: WatchFigmaScreen? {
guard enabled,
let index = ProcessInfo.processInfo.arguments.firstIndex(of: "--figma-screen"),
ProcessInfo.processInfo.arguments.indices.contains(index + 1) else { return nil }
return WatchFigmaScreen(rawValue: ProcessInfo.processInfo.arguments[index + 1])
}
static var frozen: Bool { initialScreen != nil }
}
struct WatchFigmaCanvas<Content: View>: View {
var background: Color = WatchTheme.background
@Environment(\.dismiss) private var dismiss
@ViewBuilder var content: Content
var body: some View {
GeometryReader { geometry in
content.frame(width: geometry.size.width, height: geometry.size.height)
}
.scrollContentBackground(.hidden)
.foregroundStyle(WatchTheme.text)
.tint(WatchTheme.accent)
.background(background.ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.persistentSystemOverlays(.hidden)
.accessibilityAction(.escape) { dismiss() }
}
}
struct WatchBalanceAxis: View {
var compact = false
var position: Double?
var body: some View {
HStack(spacing: 4) {
if compact { Circle().fill(Color(red: 0.957, green: 0.247, blue: 0.369)).frame(width: 6, height: 6) }
GeometryReader { geometry in
Capsule().fill(LinearGradient(colors: [compact ? WatchTheme.accent.opacity(0) : Color(red: 0.957, green: 0.247, blue: 0.369), WatchTheme.accent], startPoint: .leading, endPoint: .trailing))
if !compact, let position {
Capsule().fill(WatchTheme.text).frame(width: 4, height: 14)
.position(x: geometry.size.width * min(1, max(0, position)), y: geometry.size.height / 2)
.shadow(color: .white.opacity(0.5), radius: 4)
}
}.frame(width: compact ? 32 : 120, height: compact ? 4 : 6)
if compact { Circle().fill(WatchTheme.accent).frame(width: 6, height: 6) }
}.accessibilityHidden(true)
}
}