Add premium gating UX for color presets

Visual feedback for non-premium users:
- Premium colors show lock overlay with darkened circle
- Crown icon is hollow (outline) when locked, filled when unlocked
- Text is dimmed for locked colors
- Custom color shows rainbow gradient with lock overlay

Interaction behavior:
- Tapping locked premium color opens paywall
- Tapping locked custom color opens paywall
- Non-premium presets (Pure White, Warm Cream) remain fully accessible
- Premium users see unlocked UI with filled crown icons

This helps users:
1. See what premium features are available
2. Easily distinguish free vs premium colors
3. Test both states by toggling ENABLE_DEBUG_PREMIUM env var
This commit is contained in:
Matt Bruce 2026-01-02 15:58:46 -06:00
parent a384db1c84
commit 0f2655593f
2 changed files with 151 additions and 60 deletions

View File

@ -77,7 +77,7 @@
<EnvironmentVariables> <EnvironmentVariables>
<EnvironmentVariable <EnvironmentVariable
key = "ENABLE_DEBUG_PREMIUM" key = "ENABLE_DEBUG_PREMIUM"
value = "true" value = "1"
isEnabled = "YES"> isEnabled = "YES">
</EnvironmentVariable> </EnvironmentVariable>
</EnvironmentVariables> </EnvironmentVariables>

View File

@ -5,6 +5,12 @@ 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 premiumManager = PremiumManager()
/// Whether premium features are unlocked (for UI gating)
private var isPremiumUnlocked: Bool {
premiumManager.isPremiumUnlocked
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@ -157,9 +163,18 @@ struct SettingsView: View {
ForEach(RingLightColor.allPresets) { preset in ForEach(RingLightColor.allPresets) { preset in
ColorPresetButton( ColorPresetButton(
preset: preset, preset: preset,
isSelected: viewModel.selectedLightColor == preset isSelected: viewModel.selectedLightColor == preset,
isPremiumUnlocked: isPremiumUnlocked
) { ) {
viewModel.selectedLightColor = preset // Premium colors require unlock
if preset.isPremium && !isPremiumUnlocked {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showPaywall = true
}
} else {
viewModel.selectedLightColor = preset
}
} }
} }
@ -169,7 +184,14 @@ struct SettingsView: View {
get: { viewModel.customColor }, get: { viewModel.customColor },
set: { viewModel.selectCustomColor($0) } set: { viewModel.selectCustomColor($0) }
), ),
isSelected: viewModel.isCustomColorSelected isSelected: viewModel.isCustomColorSelected,
isPremiumUnlocked: isPremiumUnlocked,
onPremiumRequired: {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showPaywall = true
}
}
) )
} }
} }
@ -308,36 +330,55 @@ struct SettingsView: View {
private struct ColorPresetButton: View { private struct ColorPresetButton: View {
let preset: RingLightColor let preset: RingLightColor
let isSelected: Bool let isSelected: Bool
let isPremiumUnlocked: Bool
let action: () -> Void let action: () -> Void
/// Whether this premium color is locked (not available)
private var isLocked: Bool {
preset.isPremium && !isPremiumUnlocked
}
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
VStack(spacing: Design.Spacing.xxSmall) { VStack(spacing: Design.Spacing.xxSmall) {
Circle() ZStack {
.fill(preset.color) Circle()
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) .fill(preset.color)
.overlay( .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.overlay(
Circle()
.strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
)
)
.shadow(
color: preset.color.opacity(Design.Opacity.light),
radius: isSelected ? Design.Shadow.radiusSmall : 0
)
// Lock overlay for locked premium colors
if isLocked {
Circle() Circle()
.strokeBorder( .fill(.black.opacity(Design.Opacity.medium))
isSelected ? Color.Accent.primary : Color.Border.subtle, .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
) Image(systemName: "lock.fill")
) .font(.system(size: Design.BaseFontSize.small))
.shadow( .foregroundStyle(.white)
color: preset.color.opacity(Design.Opacity.light), }
radius: isSelected ? Design.Shadow.radiusSmall : 0 }
)
Text(preset.name) Text(preset.name)
.font(.system(size: Design.BaseFontSize.xSmall)) .font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent)) .foregroundStyle(.white.opacity(isSelected ? 1.0 : (isLocked ? Design.Opacity.medium : Design.Opacity.accent)))
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight) .minimumScaleFactor(Design.MinScaleFactor.tight)
if preset.isPremium { if preset.isPremium {
Image(systemName: "crown.fill") Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown")
.font(.system(size: Design.BaseFontSize.xxSmall)) .font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(Color.Status.warning) .foregroundStyle(isPremiumUnlocked ? Color.Status.warning : Color.Status.warning.opacity(Design.Opacity.medium))
} }
} }
.padding(Design.Spacing.xSmall) .padding(Design.Spacing.xSmall)
@ -349,59 +390,109 @@ private struct ColorPresetButton: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(preset.name) .accessibilityLabel(preset.name)
.accessibilityAddTraits(isSelected ? .isSelected : []) .accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityHint(preset.isPremium ? String(localized: "Premium color") : "") .accessibilityHint(isLocked ? String(localized: "Locked. Tap to unlock with Pro.") : (preset.isPremium ? String(localized: "Premium color") : ""))
} }
} }
// MARK: - Custom Color Picker Button // MARK: - Custom Color Picker Button
/// One-step custom color picker: always shows current color, tapping opens iOS color picker /// Custom color picker with premium gating
private struct CustomColorPickerButton: View { private struct CustomColorPickerButton: View {
@Binding var customColor: Color @Binding var customColor: Color
let isSelected: Bool let isSelected: Bool
let isPremiumUnlocked: Bool
let onPremiumRequired: () -> Void
/// Whether the custom color is locked
private var isLocked: Bool { !isPremiumUnlocked }
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.xxSmall) { if isPremiumUnlocked {
// ColorPicker styled as a circle // Premium users get the full color picker
ColorPicker( VStack(spacing: Design.Spacing.xxSmall) {
selection: $customColor, ColorPicker(
supportsOpacity: false selection: $customColor,
) { supportsOpacity: false
EmptyView() ) {
EmptyView()
}
.labelsHidden()
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.clipShape(.circle)
.overlay(
Circle()
.strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
)
)
.shadow(
color: customColor.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)
} }
.labelsHidden() .padding(Design.Spacing.xSmall)
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) .background(
.clipShape(.circle) RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.overlay( .fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
Circle()
.strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
)
)
.shadow(
color: customColor.opacity(Design.Opacity.light),
radius: isSelected ? Design.Shadow.radiusSmall : 0
) )
.accessibilityLabel(String(localized: "Custom color"))
.accessibilityAddTraits(isSelected ? .isSelected : [])
} else {
// Non-premium users see a locked button that shows paywall
Button(action: onPremiumRequired) {
VStack(spacing: Design.Spacing.xxSmall) {
ZStack {
// Rainbow gradient to show what's possible
Circle()
.fill(
AngularGradient(
colors: [.red, .orange, .yellow, .green, .blue, .purple, .red],
center: .center
)
)
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.overlay(
Circle()
.strokeBorder(Color.Border.subtle, lineWidth: Design.LineWidth.thin)
)
Text(String(localized: "Custom")) // Lock overlay
.font(.system(size: Design.BaseFontSize.xSmall)) Circle()
.foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent)) .fill(.black.opacity(Design.Opacity.medium))
.lineLimit(1) .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.minimumScaleFactor(Design.MinScaleFactor.tight)
Image(systemName: "crown.fill") Image(systemName: "lock.fill")
.font(.system(size: Design.BaseFontSize.xxSmall)) .font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning) .foregroundStyle(.white)
}
Text(String(localized: "Custom"))
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
Image(systemName: "crown")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(Color.Status.warning.opacity(Design.Opacity.medium))
}
.padding(Design.Spacing.xSmall)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "Custom color"))
.accessibilityHint(String(localized: "Locked. Tap to unlock with Pro."))
} }
.padding(Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
)
.accessibilityLabel(String(localized: "Custom color"))
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityHint(String(localized: "Opens color picker. Premium feature."))
} }
} }