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
|
||||
@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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 &&
|
||||
|
||||
Loading…
Reference in New Issue
Block a user