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>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "ENABLE_DEBUG_PREMIUM"
|
key = "ENABLE_DEBUG_PREMIUM"
|
||||||
value = "true"
|
value = "1"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
|
|||||||
@ -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."))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user