Add custom color picker as premium feature

Features:
- Custom color button in Light Color section (rainbow gradient icon)
- Tapping opens color picker sheet with:
  - Live color preview
  - Native iOS ColorPicker
  - Tips for best ring light colors
- Custom color syncs across devices via iCloud
- Premium-gated with crown icon indicator

Storage:
- CustomColorRGB struct for Codable-compatible color storage
- RGB values stored separately in SyncedSettings
- Color converts to/from UIColor for RGB extraction

UI:
- Rainbow gradient when not selected, solid custom color when selected
- Sheet with Apply/Cancel buttons
- Color preview bar at top of picker
This commit is contained in:
Matt Bruce 2026-01-02 13:26:11 -06:00
parent bf5853d999
commit 95377c5950
4 changed files with 266 additions and 5 deletions

View File

@ -5,6 +5,8 @@ struct SettingsView: View {
@Bindable var viewModel: SettingsViewModel @Bindable var viewModel: SettingsViewModel
@Binding var showPaywall: Bool @Binding var showPaywall: Bool
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showColorPicker = false
@State private var tempCustomColor: Color = .white
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@ -153,6 +155,7 @@ struct SettingsView: View {
columns: [GridItem(.adaptive(minimum: 80), spacing: Design.Spacing.small)], columns: [GridItem(.adaptive(minimum: 80), spacing: Design.Spacing.small)],
spacing: Design.Spacing.small spacing: Design.Spacing.small
) { ) {
// Preset colors
ForEach(RingLightColor.allPresets) { preset in ForEach(RingLightColor.allPresets) { preset in
ColorPresetButton( ColorPresetButton(
preset: preset, preset: preset,
@ -161,9 +164,31 @@ struct SettingsView: View {
viewModel.selectedLightColor = preset viewModel.selectedLightColor = preset
} }
} }
// Custom color button (premium)
CustomColorButton(
currentColor: viewModel.customColor,
isSelected: viewModel.isCustomColorSelected
) {
tempCustomColor = viewModel.customColor
showColorPicker = true
}
} }
} }
.padding(.vertical, Design.Spacing.xSmall) .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 // 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 { #Preview {
SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false)) SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false))
.preferredColorScheme(.dark) .preferredColorScheme(.dark)

View File

@ -101,6 +101,25 @@ final class SettingsViewModel: RingLightConfigurable {
set { updateSettings { $0.lightColorId = newValue } } 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) /// Whether front flash is enabled (hides preview during capture)
var isFrontFlashEnabled: Bool { var isFrontFlashEnabled: Bool {
get { cloudSync.data.isFrontFlashEnabled } get { cloudSync.data.isFrontFlashEnabled }
@ -153,12 +172,31 @@ final class SettingsViewModel: RingLightConfigurable {
} }
var selectedLightColor: RingLightColor { var selectedLightColor: RingLightColor {
get { RingLightColor.fromId(lightColorId) } get { RingLightColor.fromId(lightColorId, customColor: customColor) }
set { lightColorId = newValue.id } set {
lightColorId = newValue.id
if newValue.isCustom {
customColor = newValue.color
}
}
} }
var lightColor: 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 // MARK: - Sync Status

View File

@ -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 // MARK: - Ring Light Color Identifier
/// Identifiable wrapper for ring light colors to use in Picker/ForEach. /// Identifiable wrapper for ring light colors to use in Picker/ForEach.
@ -37,6 +69,15 @@ struct RingLightColor: Identifiable, Equatable, Hashable {
let name: String let name: String
let color: Color let color: Color
let isPremium: Bool 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] = [ static let allPresets: [RingLightColor] = [
RingLightColor(id: "pureWhite", name: String(localized: "Pure White"), color: .RingLight.pureWhite, isPremium: false), 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) RingLightColor(id: "coolLavender", name: String(localized: "Cool Lavender"), color: .RingLight.coolLavender, isPremium: true)
] ]
static func fromId(_ id: String) -> RingLightColor { /// The custom color option (premium only)
allPresets.first { $0.id == id } ?? allPresets[0] 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]
} }
} }

View File

@ -35,6 +35,11 @@ struct SyncedSettings: PersistableData, Sendable {
/// ID of the selected light color preset /// ID of the selected light color preset
var lightColorId: String = "pureWhite" 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) /// Whether front flash is enabled (hides preview during capture)
var isFrontFlashEnabled: Bool = true var isFrontFlashEnabled: Bool = true
@ -103,6 +108,9 @@ struct SyncedSettings: PersistableData, Sendable {
case lastModified case lastModified
case ringSizeValue case ringSizeValue
case lightColorId case lightColorId
case customColorRed
case customColorGreen
case customColorBlue
case isFrontFlashEnabled case isFrontFlashEnabled
case isMirrorFlipped case isMirrorFlipped
case isSkinSmoothingEnabled case isSkinSmoothingEnabled
@ -120,6 +128,9 @@ extension SyncedSettings: Equatable {
static func == (lhs: SyncedSettings, rhs: SyncedSettings) -> Bool { static func == (lhs: SyncedSettings, rhs: SyncedSettings) -> Bool {
lhs.ringSizeValue == rhs.ringSizeValue && lhs.ringSizeValue == rhs.ringSizeValue &&
lhs.lightColorId == rhs.lightColorId && lhs.lightColorId == rhs.lightColorId &&
lhs.customColorRed == rhs.customColorRed &&
lhs.customColorGreen == rhs.customColorGreen &&
lhs.customColorBlue == rhs.customColorBlue &&
lhs.isFrontFlashEnabled == rhs.isFrontFlashEnabled && lhs.isFrontFlashEnabled == rhs.isFrontFlashEnabled &&
lhs.isMirrorFlipped == rhs.isMirrorFlipped && lhs.isMirrorFlipped == rhs.isMirrorFlipped &&
lhs.isSkinSmoothingEnabled == rhs.isSkinSmoothingEnabled && lhs.isSkinSmoothingEnabled == rhs.isSkinSmoothingEnabled &&