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>
<EnvironmentVariable
key = "ENABLE_DEBUG_PREMIUM"
value = "true"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>

View File

@ -5,6 +5,12 @@ struct SettingsView: View {
@Bindable var viewModel: SettingsViewModel
@Binding var showPaywall: Bool
@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 {
NavigationStack {
@ -157,9 +163,18 @@ struct SettingsView: View {
ForEach(RingLightColor.allPresets) { preset in
ColorPresetButton(
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 },
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 {
let preset: RingLightColor
let isSelected: Bool
let isPremiumUnlocked: Bool
let action: () -> Void
/// Whether this premium color is locked (not available)
private var isLocked: Bool {
preset.isPremium && !isPremiumUnlocked
}
var body: some View {
Button(action: action) {
VStack(spacing: Design.Spacing.xxSmall) {
Circle()
.fill(preset.color)
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.overlay(
ZStack {
Circle()
.fill(preset.color)
.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()
.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
)
.fill(.black.opacity(Design.Opacity.medium))
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
Image(systemName: "lock.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white)
}
}
Text(preset.name)
.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)
.minimumScaleFactor(Design.MinScaleFactor.tight)
if preset.isPremium {
Image(systemName: "crown.fill")
Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown")
.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)
@ -349,59 +390,109 @@ private struct ColorPresetButton: View {
.buttonStyle(.plain)
.accessibilityLabel(preset.name)
.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
/// One-step custom color picker: always shows current color, tapping opens iOS color picker
/// Custom color picker with premium gating
private struct CustomColorPickerButton: View {
@Binding var customColor: Color
let isSelected: Bool
let isPremiumUnlocked: Bool
let onPremiumRequired: () -> Void
/// Whether the custom color is locked
private var isLocked: Bool { !isPremiumUnlocked }
var body: some View {
VStack(spacing: Design.Spacing.xxSmall) {
// ColorPicker styled as a circle
ColorPicker(
selection: $customColor,
supportsOpacity: false
) {
EmptyView()
if isPremiumUnlocked {
// Premium users get the full color picker
VStack(spacing: Design.Spacing.xxSmall) {
ColorPicker(
selection: $customColor,
supportsOpacity: false
) {
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()
.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
)
.padding(Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
)
.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)
.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)
)
// Lock overlay
Circle()
.fill(.black.opacity(Design.Opacity.medium))
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
Image(systemName: "lock.fill")
.font(.system(size: Design.BaseFontSize.small))
.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."))
}
}