diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift index 3222a70..8cdfb96 100644 --- a/BusinessCard/Design/DesignConstants.swift +++ b/BusinessCard/Design/DesignConstants.swift @@ -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) } diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift index ad779fc..11078d4 100644 --- a/BusinessCard/Models/BusinessCard.swift +++ b/BusinessCard/Models/BusinessCard.swift @@ -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 { diff --git a/BusinessCard/Models/CardTheme.swift b/BusinessCard/Models/CardTheme.swift index a42ce20..068f21e 100644 --- a/BusinessCard/Models/CardTheme.swift +++ b/BusinessCard/Models/CardTheme.swift @@ -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) + } } diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index 0546f79..7e70beb 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -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" : { diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift index c1ee498..2a59ace 100644 --- a/BusinessCard/Views/CardEditorView.swift +++ b/BusinessCard/Views/CardEditorView.swift @@ -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 } }