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:
parent
a384db1c84
commit
0f2655593f
@ -77,7 +77,7 @@
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "ENABLE_DEBUG_PREMIUM"
|
||||
value = "true"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
.shadow(
|
||||
color: customColor.opacity(Design.Opacity.light),
|
||||
radius: isSelected ? Design.Shadow.radiusSmall : 0
|
||||
.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 : [])
|
||||
} 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"))
|
||||
.font(.system(size: Design.BaseFontSize.xSmall))
|
||||
.foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(Design.MinScaleFactor.tight)
|
||||
// Lock overlay
|
||||
Circle()
|
||||
.fill(.black.opacity(Design.Opacity.medium))
|
||||
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: Design.BaseFontSize.xxSmall))
|
||||
.foregroundStyle(Color.Status.warning)
|
||||
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."))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user