- Wrap 30+ production print() statements in #if DEBUG guards across 18 files - Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets - Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views - Add text alternatives for color-only indicators (progress dots, mood circles) - Localize raw string literals in NoteEditorView, EntryDetailView, widgets - Replace 25+ silent try? with do/catch + AppLogger error logging - Replace hardcoded font sizes with semantic Dynamic Type fonts - Fix FIXME in IconPickerView (log icon change errors) - Extract magic animation delays to named constants across 8 files - Add widget empty state "Log your first mood!" messaging - Hide decorative images from VoiceOver, add labels to ColorPickers - Remove stale TODO in Color+Codable (alpha change deferred for migration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
4.6 KiB
Swift
155 lines
4.6 KiB
Swift
//
|
|
// Color+Codable.swift
|
|
// FirestoreCodableSamples
|
|
//
|
|
// Created by Peter Friese on 18.03.21.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
// Inspired by https://cocoacasts.com/from-hex-to-uicolor-and-back-in-swift
|
|
// Make Color codable. This includes support for transparency.
|
|
// See https://www.digitalocean.com/community/tutorials/css-hex-code-colors-alpha-values
|
|
extension Color: @retroactive Codable {
|
|
init(hex: String) {
|
|
let rgba = hex.toRGBA()
|
|
|
|
self.init(.sRGB,
|
|
red: Double(rgba.r),
|
|
green: Double(rgba.g),
|
|
blue: Double(rgba.b),
|
|
opacity: Double(rgba.alpha))
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
let hex = try container.decode(String.self)
|
|
|
|
self.init(hex: hex)
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.singleValueContainer()
|
|
try container.encode(toHex)
|
|
}
|
|
|
|
var toHex: String? {
|
|
return toHex()
|
|
}
|
|
|
|
func toHex(alpha: Bool = false) -> String? {
|
|
guard let components = cgColor?.components, components.count >= 3 else {
|
|
return nil
|
|
}
|
|
|
|
let r = Float(components[0])
|
|
let g = Float(components[1])
|
|
let b = Float(components[2])
|
|
var a = Float(1.0)
|
|
|
|
if components.count >= 4 {
|
|
a = Float(components[3])
|
|
}
|
|
|
|
if alpha {
|
|
return String(format: "%02lX%02lX%02lX%02lX",
|
|
lroundf(r * 255),
|
|
lroundf(g * 255),
|
|
lroundf(b * 255),
|
|
lroundf(a * 255))
|
|
}
|
|
else {
|
|
return String(format: "%02lX%02lX%02lX",
|
|
lroundf(r * 255),
|
|
lroundf(g * 255),
|
|
lroundf(b * 255))
|
|
}
|
|
}
|
|
}
|
|
|
|
extension String {
|
|
func toRGBA() -> (r: CGFloat, g: CGFloat, b: CGFloat, alpha: CGFloat) {
|
|
var hexSanitized = self.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
|
|
|
var rgb: UInt64 = 0
|
|
|
|
var r: CGFloat = 0.0
|
|
var g: CGFloat = 0.0
|
|
var b: CGFloat = 0.0
|
|
var a: CGFloat = 1.0
|
|
|
|
let length = hexSanitized.count
|
|
|
|
Scanner(string: hexSanitized).scanHexInt64(&rgb)
|
|
|
|
if length == 6 {
|
|
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
|
|
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
|
|
b = CGFloat(rgb & 0x0000FF) / 255.0
|
|
}
|
|
else if length == 8 {
|
|
r = CGFloat((rgb & 0xFF000000) >> 24) / 255.0
|
|
g = CGFloat((rgb & 0x00FF0000) >> 16) / 255.0
|
|
b = CGFloat((rgb & 0x0000FF00) >> 8) / 255.0
|
|
a = CGFloat(rgb & 0x000000FF) / 255.0
|
|
}
|
|
|
|
return (r, g, b, a)
|
|
}
|
|
}
|
|
|
|
// MARK: - Luminance & Contrast
|
|
|
|
extension Color {
|
|
/// Calculate relative luminance using WCAG formula
|
|
/// Returns value between 0 (darkest) and 1 (lightest)
|
|
var luminance: Double {
|
|
guard let components = cgColor?.components, components.count >= 3 else {
|
|
return 0.5 // Default to mid-gray if can't determine
|
|
}
|
|
|
|
let r = components[0]
|
|
let g = components[1]
|
|
let b = components[2]
|
|
|
|
// Apply gamma correction per WCAG 2.0
|
|
func adjust(_ component: CGFloat) -> Double {
|
|
let c = Double(component)
|
|
return c <= 0.03928 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
|
|
}
|
|
|
|
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
|
|
}
|
|
|
|
/// Returns true if the color is considered "light" (needs dark text for contrast)
|
|
var isLight: Bool {
|
|
luminance > 0.5
|
|
}
|
|
|
|
/// Returns black or white depending on which provides better contrast
|
|
var contrastingTextColor: Color {
|
|
isLight ? .black : .white
|
|
}
|
|
}
|
|
|
|
extension Color: @retroactive RawRepresentable {
|
|
public init?(rawValue: Int) {
|
|
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
|
|
let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF
|
|
let blue = Double(rawValue & 0x0000FF) / 0xFF
|
|
self = Color(red: red, green: green, blue: blue)
|
|
}
|
|
|
|
public var rawValue: Int {
|
|
let red = Int(coreImageColor.red * 255 + 0.5)
|
|
let green = Int(coreImageColor.green * 255 + 0.5)
|
|
let blue = Int(coreImageColor.blue * 255 + 0.5)
|
|
return (red << 16) | (green << 8) | blue
|
|
}
|
|
|
|
private var coreImageColor: CIColor {
|
|
return CIColor(color: UIColor(self))
|
|
}
|
|
}
|