diff --git a/SelfieRingLight/Features/Settings/SettingsView.swift b/SelfieRingLight/Features/Settings/SettingsView.swift index 4604b7a..7de9507 100644 --- a/SelfieRingLight/Features/Settings/SettingsView.swift +++ b/SelfieRingLight/Features/Settings/SettingsView.swift @@ -5,6 +5,8 @@ struct SettingsView: View { @Bindable var viewModel: SettingsViewModel @Binding var showPaywall: Bool @Environment(\.dismiss) private var dismiss + @State private var showColorPicker = false + @State private var tempCustomColor: Color = .white var body: some View { NavigationStack { @@ -153,6 +155,7 @@ struct SettingsView: View { columns: [GridItem(.adaptive(minimum: 80), spacing: Design.Spacing.small)], spacing: Design.Spacing.small ) { + // Preset colors ForEach(RingLightColor.allPresets) { preset in ColorPresetButton( preset: preset, @@ -161,9 +164,31 @@ struct SettingsView: View { viewModel.selectedLightColor = preset } } + + // Custom color button (premium) + CustomColorButton( + currentColor: viewModel.customColor, + isSelected: viewModel.isCustomColorSelected + ) { + tempCustomColor = viewModel.customColor + showColorPicker = true + } } } .padding(.vertical, Design.Spacing.xSmall) + .sheet(isPresented: $showColorPicker) { + CustomColorPickerSheet( + selectedColor: $tempCustomColor, + onApply: { + viewModel.selectCustomColor(tempCustomColor) + showColorPicker = false + }, + onCancel: { + showColorPicker = false + } + ) + .presentationDetents([.medium]) + } } // MARK: - Timer Picker @@ -343,6 +368,136 @@ private struct ColorPresetButton: View { } } +// MARK: - Custom Color Button + +private struct CustomColorButton: View { + let currentColor: Color + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: Design.Spacing.xxSmall) { + // Rainbow gradient circle to indicate custom picker + ZStack { + // Show rainbow gradient when not selected, custom color when selected + if isSelected { + Circle() + .fill(currentColor) + } else { + Circle() + .fill( + AngularGradient( + colors: [.red, .orange, .yellow, .green, .blue, .purple, .red], + center: .center + ) + ) + } + + Circle() + .strokeBorder( + isSelected ? Color.Accent.primary : Color.Border.subtle, + lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin + ) + } + .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) + .shadow( + color: currentColor.opacity(Design.Opacity.light), + radius: isSelected ? Design.Shadow.radiusSmall : 0 + ) + + Text(String(localized: "Custom")) + .font(.system(size: Design.BaseFontSize.xSmall)) + .foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent)) + .lineLimit(1) + .minimumScaleFactor(Design.MinScaleFactor.tight) + + Image(systemName: "crown.fill") + .font(.system(size: Design.BaseFontSize.xxSmall)) + .foregroundStyle(Color.Status.warning) + } + .padding(Design.Spacing.xSmall) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.small) + .fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Custom color")) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityHint(String(localized: "Opens color picker. Premium feature.")) + } +} + +// MARK: - Custom Color Picker Sheet + +private struct CustomColorPickerSheet: View { + @Binding var selectedColor: Color + let onApply: () -> Void + let onCancel: () -> Void + + var body: some View { + NavigationStack { + VStack(spacing: Design.Spacing.large) { + // Color preview + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .fill(selectedColor) + .frame(height: 120) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .strokeBorder(Color.Border.subtle, lineWidth: Design.LineWidth.thin) + ) + .padding(.horizontal, Design.Spacing.large) + .padding(.top, Design.Spacing.medium) + + // SwiftUI ColorPicker + ColorPicker( + selection: $selectedColor, + supportsOpacity: false + ) { + Text(String(localized: "Select Color")) + .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) + .foregroundStyle(.white) + } + .padding(.horizontal, Design.Spacing.large) + + // Tips + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + Label( + String(localized: "Lighter colors work best as ring lights"), + systemImage: "lightbulb.fill" + ) + .font(.system(size: Design.BaseFontSize.caption)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Design.Spacing.large) + + Spacer() + } + .background(Color.Surface.overlay) + .navigationTitle(String(localized: "Custom Color")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(String(localized: "Cancel")) { + onCancel() + } + .foregroundStyle(.white) + } + + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "Apply")) { + onApply() + } + .foregroundStyle(Color.Accent.primary) + .bold() + } + } + } + } +} + #Preview { SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false)) .preferredColorScheme(.dark) diff --git a/SelfieRingLight/Features/Settings/SettingsViewModel.swift b/SelfieRingLight/Features/Settings/SettingsViewModel.swift index 74b3667..1add39b 100644 --- a/SelfieRingLight/Features/Settings/SettingsViewModel.swift +++ b/SelfieRingLight/Features/Settings/SettingsViewModel.swift @@ -101,6 +101,25 @@ final class SettingsViewModel: RingLightConfigurable { set { updateSettings { $0.lightColorId = newValue } } } + /// Custom color for ring light (premium feature) + var customColor: Color { + get { + Color( + red: cloudSync.data.customColorRed, + green: cloudSync.data.customColorGreen, + blue: cloudSync.data.customColorBlue + ) + } + set { + let rgb = CustomColorRGB(from: newValue) + updateSettings { + $0.customColorRed = rgb.red + $0.customColorGreen = rgb.green + $0.customColorBlue = rgb.blue + } + } + } + /// Whether front flash is enabled (hides preview during capture) var isFrontFlashEnabled: Bool { get { cloudSync.data.isFrontFlashEnabled } @@ -153,12 +172,31 @@ final class SettingsViewModel: RingLightConfigurable { } var selectedLightColor: RingLightColor { - get { RingLightColor.fromId(lightColorId) } - set { lightColorId = newValue.id } + get { RingLightColor.fromId(lightColorId, customColor: customColor) } + set { + lightColorId = newValue.id + if newValue.isCustom { + customColor = newValue.color + } + } } var lightColor: Color { - selectedLightColor.color + if lightColorId == RingLightColor.customId { + return customColor + } + return selectedLightColor.color + } + + /// Whether custom color is currently selected + var isCustomColorSelected: Bool { + lightColorId == RingLightColor.customId + } + + /// Sets the custom color and selects it + func selectCustomColor(_ color: Color) { + customColor = color + lightColorId = RingLightColor.customId } // MARK: - Sync Status diff --git a/SelfieRingLight/Shared/Color+Extensions.swift b/SelfieRingLight/Shared/Color+Extensions.swift index 9cfb0bc..b3b55c2 100644 --- a/SelfieRingLight/Shared/Color+Extensions.swift +++ b/SelfieRingLight/Shared/Color+Extensions.swift @@ -29,6 +29,38 @@ extension Color { } } +// MARK: - Custom Color RGB Storage + +/// Stores RGB values for custom colors (Codable-friendly) +struct CustomColorRGB: Codable, Equatable, Sendable { + var red: Double + var green: Double + var blue: Double + + static let defaultWhite = CustomColorRGB(red: 1.0, green: 1.0, blue: 1.0) + + var color: Color { + Color(red: red, green: green, blue: blue) + } + + init(red: Double, green: Double, blue: Double) { + self.red = red + self.green = green + self.blue = blue + } + + init(from color: Color) { + let uiColor = UIColor(color) + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + uiColor.getRed(&r, green: &g, blue: &b, alpha: nil) + self.red = Double(r) + self.green = Double(g) + self.blue = Double(b) + } +} + // MARK: - Ring Light Color Identifier /// Identifiable wrapper for ring light colors to use in Picker/ForEach. @@ -37,6 +69,15 @@ struct RingLightColor: Identifiable, Equatable, Hashable { let name: String let color: Color let isPremium: Bool + let isCustom: Bool + + init(id: String, name: String, color: Color, isPremium: Bool, isCustom: Bool = false) { + self.id = id + self.name = name + self.color = color + self.isPremium = isPremium + self.isCustom = isCustom + } static let allPresets: [RingLightColor] = [ RingLightColor(id: "pureWhite", name: String(localized: "Pure White"), color: .RingLight.pureWhite, isPremium: false), @@ -47,7 +88,23 @@ struct RingLightColor: Identifiable, Equatable, Hashable { RingLightColor(id: "coolLavender", name: String(localized: "Cool Lavender"), color: .RingLight.coolLavender, isPremium: true) ] - static func fromId(_ id: String) -> RingLightColor { - allPresets.first { $0.id == id } ?? allPresets[0] + /// The custom color option (premium only) + static let customId = "custom" + + static func custom(with color: Color) -> RingLightColor { + RingLightColor( + id: customId, + name: String(localized: "Custom"), + color: color, + isPremium: true, + isCustom: true + ) + } + + static func fromId(_ id: String, customColor: Color? = nil) -> RingLightColor { + if id == customId, let customColor { + return custom(with: customColor) + } + return allPresets.first { $0.id == id } ?? allPresets[0] } } diff --git a/SelfieRingLight/Shared/Storage/SyncedSettings.swift b/SelfieRingLight/Shared/Storage/SyncedSettings.swift index 341bf43..2626717 100644 --- a/SelfieRingLight/Shared/Storage/SyncedSettings.swift +++ b/SelfieRingLight/Shared/Storage/SyncedSettings.swift @@ -35,6 +35,11 @@ struct SyncedSettings: PersistableData, Sendable { /// ID of the selected light color preset var lightColorId: String = "pureWhite" + /// Custom color RGB values (for premium custom color picker) + var customColorRed: Double = 1.0 + var customColorGreen: Double = 1.0 + var customColorBlue: Double = 1.0 + /// Whether front flash is enabled (hides preview during capture) var isFrontFlashEnabled: Bool = true @@ -103,6 +108,9 @@ struct SyncedSettings: PersistableData, Sendable { case lastModified case ringSizeValue case lightColorId + case customColorRed + case customColorGreen + case customColorBlue case isFrontFlashEnabled case isMirrorFlipped case isSkinSmoothingEnabled @@ -120,6 +128,9 @@ extension SyncedSettings: Equatable { static func == (lhs: SyncedSettings, rhs: SyncedSettings) -> Bool { lhs.ringSizeValue == rhs.ringSizeValue && lhs.lightColorId == rhs.lightColorId && + lhs.customColorRed == rhs.customColorRed && + lhs.customColorGreen == rhs.customColorGreen && + lhs.customColorBlue == rhs.customColorBlue && lhs.isFrontFlashEnabled == rhs.isFrontFlashEnabled && lhs.isMirrorFlipped == rhs.isMirrorFlipped && lhs.isSkinSmoothingEnabled == rhs.isSkinSmoothingEnabled &&