Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-09 12:00:15 -06:00
parent 4145f73bc9
commit dff3e51b61
5 changed files with 330 additions and 25 deletions

View File

@ -65,6 +65,11 @@ extension Color {
static let ocean = Color(red: 0.08, green: 0.45, blue: 0.56)
static let lime = Color(red: 0.73, green: 0.82, blue: 0.34)
static let violet = Color(red: 0.42, green: 0.36, blue: 0.62)
static let forest = Color(red: 0.13, green: 0.37, blue: 0.31)
static let rose = Color(red: 0.95, green: 0.68, blue: 0.73)
static let slate = Color(red: 0.38, green: 0.44, blue: 0.50)
static let amber = Color(red: 0.98, green: 0.75, blue: 0.28)
static let plum = Color(red: 0.56, green: 0.27, blue: 0.52)
static let sand = Color(red: 0.93, green: 0.83, blue: 0.68)
}

View File

@ -101,8 +101,8 @@ final class BusinessCard {
@MainActor
var theme: CardTheme {
get { CardTheme(rawValue: themeName) ?? .coral }
set { themeName = newValue.rawValue }
get { CardTheme(named: themeName) }
set { themeName = newValue.name }
}
var layoutStyle: CardLayoutStyle {

View File

@ -1,66 +1,165 @@
import SwiftUI
/// Card theme identifier - stores just the name, colors computed on MainActor
enum CardTheme: String, CaseIterable, Identifiable, Hashable, Sendable {
case coral = "Coral"
case midnight = "Midnight"
case ocean = "Ocean"
case lime = "Lime"
case violet = "Violet"
/// Card theme configuration supporting both preset themes and custom colors.
///
/// Themes are stored as strings in the format:
/// - Preset themes: "Coral", "Midnight", etc.
/// - Custom themes: "custom:R,G,B" where R, G, B are 0.0-1.0 values
struct CardTheme: Identifiable, Hashable, Sendable {
let name: String
private let customRGB: (Double, Double, Double)?
var id: String { rawValue }
var name: String { rawValue }
var id: String { name }
// MARK: - Preset Themes
static let coral = CardTheme(preset: .coral)
static let midnight = CardTheme(preset: .midnight)
static let ocean = CardTheme(preset: .ocean)
static let lime = CardTheme(preset: .lime)
static let violet = CardTheme(preset: .violet)
static let forest = CardTheme(preset: .forest)
static let rose = CardTheme(preset: .rose)
static let slate = CardTheme(preset: .slate)
static let amber = CardTheme(preset: .amber)
static let plum = CardTheme(preset: .plum)
/// All preset themes (excludes custom)
static var all: [CardTheme] {
Preset.allCases.map { CardTheme(preset: $0) }
}
// MARK: - Initialization
private init(preset: Preset) {
self.name = preset.rawValue
self.customRGB = nil
}
/// Creates a custom theme with the specified RGB values (0.0-1.0 range)
init(customRed: Double, customGreen: Double, customBlue: Double) {
self.name = String(format: "custom:%.3f,%.3f,%.3f", customRed, customGreen, customBlue)
self.customRGB = (customRed, customGreen, customBlue)
}
/// Creates a theme from a stored name string
init(named themeName: String) {
if themeName.hasPrefix("custom:") {
let values = themeName.dropFirst(7).split(separator: ",")
if values.count == 3,
let r = Double(values[0]),
let g = Double(values[1]),
let b = Double(values[2]) {
self.name = themeName
self.customRGB = (r, g, b)
} else {
// Fallback to coral if parsing fails
self.name = Preset.coral.rawValue
self.customRGB = nil
}
} else if Preset(rawValue: themeName) != nil {
self.name = themeName
self.customRGB = nil
} else {
// Fallback to coral for unknown themes
self.name = Preset.coral.rawValue
self.customRGB = nil
}
}
// MARK: - Properties
var isCustom: Bool { customRGB != nil }
var localizedName: String {
String.localized(rawValue)
if isCustom {
return String.localized("Custom")
}
return String.localized(name)
}
static func theme(named name: String) -> CardTheme {
CardTheme(rawValue: name) ?? .coral
private var preset: Preset? {
Preset(rawValue: name)
}
static var all: [CardTheme] { allCases }
// MARK: - Theme Brightness
/// Whether this theme requires dark text for proper contrast.
/// Light backgrounds need dark text; dark backgrounds need light text.
var requiresDarkText: Bool {
switch self {
case .lime: return true
case .coral, .midnight, .ocean, .violet: return false
if let rgb = customRGB {
// Calculate perceived luminance for custom colors
let luminance = 0.299 * rgb.0 + 0.587 * rgb.1 + 0.114 * rgb.2
return luminance > 0.5
}
guard let preset else { return false }
switch preset {
case .lime, .amber, .rose:
return true
case .coral, .midnight, .ocean, .violet, .forest, .slate, .plum:
return false
}
}
// MARK: - RGB Values (nonisolated)
// MARK: - RGB Values
private var primaryRGB: (Double, Double, Double) {
switch self {
if let rgb = customRGB { return rgb }
guard let preset else { return (0.95, 0.35, 0.33) }
switch preset {
case .coral: return (0.95, 0.35, 0.33)
case .midnight: return (0.12, 0.16, 0.22)
case .ocean: return (0.08, 0.45, 0.56)
case .lime: return (0.73, 0.82, 0.34)
case .violet: return (0.42, 0.36, 0.62)
case .forest: return (0.13, 0.37, 0.31)
case .rose: return (0.95, 0.68, 0.73)
case .slate: return (0.38, 0.44, 0.50)
case .amber: return (0.98, 0.75, 0.28)
case .plum: return (0.56, 0.27, 0.52)
}
}
private var secondaryRGB: (Double, Double, Double) {
switch self {
if let rgb = customRGB {
// Create a lighter/darker variant for gradient
return (
min(1.0, rgb.0 + 0.2),
min(1.0, rgb.1 + 0.15),
min(1.0, rgb.2 + 0.1)
)
}
guard let preset else { return (0.93, 0.83, 0.68) }
switch preset {
case .coral: return (0.93, 0.83, 0.68)
case .midnight: return (0.29, 0.33, 0.4)
case .ocean: return (0.2, 0.65, 0.55)
case .lime: return (0.93, 0.83, 0.68)
case .violet: return (0.29, 0.33, 0.4)
case .forest: return (0.22, 0.52, 0.42)
case .rose: return (0.98, 0.85, 0.88)
case .slate: return (0.52, 0.58, 0.64)
case .amber: return (0.99, 0.88, 0.55)
case .plum: return (0.72, 0.45, 0.68)
}
}
private var accentRGB: (Double, Double, Double) {
switch self {
if customRGB != nil {
// For custom colors, use a contrasting accent (gold or dark)
return requiresDarkText ? (0.12, 0.12, 0.14) : (0.95, 0.75, 0.25)
}
guard let preset else { return (0.95, 0.33, 0.28) }
switch preset {
case .coral: return (0.95, 0.33, 0.28)
case .midnight: return (0.95, 0.75, 0.25)
case .ocean: return (0.95, 0.75, 0.25)
case .lime: return (0.12, 0.12, 0.14)
case .violet: return (0.95, 0.75, 0.25)
case .forest: return (0.95, 0.75, 0.25)
case .rose: return (0.75, 0.25, 0.35)
case .slate: return (0.95, 0.75, 0.25)
case .amber: return (0.12, 0.12, 0.14)
case .plum: return (0.95, 0.75, 0.25)
}
}
@ -88,4 +187,41 @@ enum CardTheme: String, CaseIterable, Identifiable, Hashable, Sendable {
@MainActor var textColor: Color {
Color(red: textRGB.0, green: textRGB.1, blue: textRGB.2)
}
// MARK: - Hashable & Equatable
static func == (lhs: CardTheme, rhs: CardTheme) -> Bool {
lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(name)
}
}
// MARK: - Preset Theme Cases
extension CardTheme {
/// Internal enum for preset themes
enum Preset: String, CaseIterable {
case coral = "Coral"
case midnight = "Midnight"
case ocean = "Ocean"
case lime = "Lime"
case violet = "Violet"
case forest = "Forest"
case rose = "Rose"
case slate = "Slate"
case amber = "Amber"
case plum = "Plum"
}
}
// MARK: - Convenience Initializer
extension CardTheme {
/// Creates a theme from a stored name string (convenience for property access)
static func theme(named name: String) -> CardTheme {
CardTheme(named: name)
}
}

View File

@ -164,6 +164,9 @@
},
"Choose a card in the My Cards tab to start sharing." : {
},
"Choose your color" : {
},
"City" : {
@ -464,6 +467,9 @@
},
"Prefix (e.g. Dr., Mr., Ms.)" : {
},
"Preview" : {
},
"Preview card" : {

View File

@ -306,10 +306,13 @@ struct CardEditorView: View {
private struct CardStylePicker: View {
@Binding var selectedTheme: CardTheme
@State private var showingColorPicker = false
@State private var customColor: Color = .blue
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.medium) {
// Preset themes
ForEach(CardTheme.all) { theme in
Button {
selectedTheme = theme
@ -324,11 +327,166 @@ private struct CardStylePicker: View {
)
}
.buttonStyle(.plain)
.accessibilityLabel(theme.name)
.accessibilityLabel(theme.localizedName)
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
}
// Custom color option
Button {
showingColorPicker = true
} label: {
CustomColorSwatch(
isSelected: selectedTheme.isCustom,
customColor: selectedTheme.isCustom ? selectedTheme.primaryColor : nil
)
}
.buttonStyle(.plain)
.accessibilityLabel(String.localized("Custom color"))
.accessibilityHint(String.localized("Opens color picker to choose a custom color"))
.accessibilityAddTraits(selectedTheme.isCustom ? .isSelected : [])
}
}
.sheet(isPresented: $showingColorPicker) {
CustomColorPickerSheet(
initialColor: selectedTheme.isCustom ? selectedTheme.primaryColor : .blue
) { color in
// Convert Color to RGB components
if let components = color.rgbComponents {
selectedTheme = CardTheme(
customRed: components.red,
customGreen: components.green,
customBlue: components.blue
)
}
}
}
.onAppear {
if selectedTheme.isCustom {
customColor = selectedTheme.primaryColor
}
}
}
}
// MARK: - Custom Color Swatch
private struct CustomColorSwatch: View {
let isSelected: Bool
let customColor: Color?
private let rainbowGradient = AngularGradient(
colors: [.red, .orange, .yellow, .green, .blue, .purple, .red],
center: .center
)
var body: some View {
ZStack {
if let customColor {
// Show the selected custom color
Circle()
.fill(customColor)
} else {
// Show rainbow gradient to indicate "pick any color"
Circle()
.fill(rainbowGradient)
}
// Center icon to indicate it's a picker
if customColor == nil {
Image(systemName: "eyedropper")
.font(.caption)
.foregroundStyle(.white)
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusSmall)
}
}
.frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
.overlay(
Circle()
.stroke(isSelected ? Color.primary : .clear, lineWidth: Design.LineWidth.medium)
.padding(Design.Spacing.xxSmall)
)
}
}
// MARK: - Custom Color Picker Sheet
private struct CustomColorPickerSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedColor: Color
let onSelectColor: (Color) -> Void
init(initialColor: Color, onSelectColor: @escaping (Color) -> Void) {
self._selectedColor = State(initialValue: initialColor)
self.onSelectColor = onSelectColor
}
var body: some View {
NavigationStack {
VStack(spacing: Design.Spacing.xLarge) {
// Preview of selected color
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(selectedColor)
.frame(height: Design.CardSize.bannerHeight)
.overlay(
Text("Preview")
.font(.headline)
.foregroundStyle(selectedColor.contrastingTextColor)
)
.padding(.horizontal, Design.Spacing.large)
// Color picker
ColorPicker("Choose your color", selection: $selectedColor, supportsOpacity: false)
.labelsHidden()
.scaleEffect(1.5)
.frame(height: Design.CardSize.avatarLarge)
Spacer()
}
.padding(.top, Design.Spacing.xLarge)
.navigationTitle(String.localized("Custom Color"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String.localized("Cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Done")) {
onSelectColor(selectedColor)
dismiss()
}
.bold()
}
}
}
.presentationDetents([.medium])
}
}
// MARK: - Color Extensions for RGB Extraction
private extension Color {
/// Extracts RGB components from a Color (0.0-1.0 range)
var rgbComponents: (red: Double, green: Double, blue: Double)? {
let uiColor = UIColor(self)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return nil
}
return (Double(red), Double(green), Double(blue))
}
/// Returns a contrasting text color (white or black) based on luminance
var contrastingTextColor: Color {
guard let components = rgbComponents else { return .white }
let luminance = 0.299 * components.red + 0.587 * components.green + 0.114 * components.blue
return luminance > 0.5 ? .black : .white
}
}