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:
parent
bf5853d999
commit
95377c5950
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 &&
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user