Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
4145f73bc9
commit
dff3e51b61
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" : {
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user