From 0f2655593f902874218e22c16f68ca8e079204c7 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 2 Jan 2026 15:58:46 -0600 Subject: [PATCH] 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 --- .../xcschemes/SelfieRingLight.xcscheme | 2 +- .../Features/Settings/SettingsView.swift | 209 +++++++++++++----- 2 files changed, 151 insertions(+), 60 deletions(-) diff --git a/SelfieRingLight.xcodeproj/xcshareddata/xcschemes/SelfieRingLight.xcscheme b/SelfieRingLight.xcodeproj/xcshareddata/xcschemes/SelfieRingLight.xcscheme index 3f92b9d..3ec327e 100644 --- a/SelfieRingLight.xcodeproj/xcshareddata/xcschemes/SelfieRingLight.xcscheme +++ b/SelfieRingLight.xcodeproj/xcshareddata/xcschemes/SelfieRingLight.xcscheme @@ -77,7 +77,7 @@ diff --git a/SelfieRingLight/Features/Settings/SettingsView.swift b/SelfieRingLight/Features/Settings/SettingsView.swift index 461f5e4..0dc2c3a 100644 --- a/SelfieRingLight/Features/Settings/SettingsView.swift +++ b/SelfieRingLight/Features/Settings/SettingsView.swift @@ -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.")) } }