Add Neon/Synthwave style and 4 paywall themes

- Add 4 distinct paywall themes (Celestial, Garden, Neon, Minimal) with
  preview/switcher in debug settings
- Add Neon voting layout with synthwave equalizer bar design
- Upgrade Neon entry style with grid background, cyan/magenta gradients,
  scanline effects, and mini equalizer visualization
- Add PaywallPreviewSettingsView for testing different paywall styles
- Use consistent synthwave color palette (cyan #00FFD0, magenta #FF00CC)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-26 23:05:45 -06:00
parent f45f52ccbf
commit 53eb953b77
9 changed files with 2144 additions and 117 deletions

View File

@@ -1133,6 +1133,10 @@
}
}
},
"Amplify your emotional intelligence.\nGo premium. Go limitless." : {
"comment" : "A description of the premium subscription experience, emphasizing its benefits.",
"isCommentAutoGenerated" : true
},
"Animation Lab" : {
},
@@ -1783,6 +1787,10 @@
}
}
},
"Clarity through simplicity.\nPremium unlocks understanding." : {
"comment" : "A description of the benefits of the premium subscription.",
"isCommentAutoGenerated" : true
},
"Clear DB" : {
"comment" : "A button label that clears the app's database.",
"isCommentAutoGenerated" : true,
@@ -4666,6 +4674,10 @@
}
}
},
"Every feeling is a seed.\nPremium helps you grow." : {
"comment" : "A description of the premium subscription's benefits.",
"isCommentAutoGenerated" : true
},
"Exit" : {
"comment" : "A button label that dismisses the current view.",
"isCommentAutoGenerated" : true,
@@ -5482,6 +5494,7 @@
},
"Get unlimited access to all features" : {
"comment" : "A description of the benefits of purchasing the premium version of the app.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"de" : {
@@ -5916,6 +5929,10 @@
}
}
},
"Join 50,000+ on their journey" : {
"comment" : "A description of the social proof badge.",
"isCommentAutoGenerated" : true
},
"Journal Note" : {
"comment" : "The title of the view that appears in the navigation bar.",
"isCommentAutoGenerated" : true,
@@ -8657,6 +8674,12 @@
}
}
}
},
"Paywall Styles" : {
},
"Paywall Theme Lab" : {
},
"Personalize Your Experience" : {
"comment" : "A title for a tip that encourages users to customize their mood tracking experience.",
@@ -8996,6 +9019,16 @@
"comment" : "A description of a premium feature that requires a subscription.",
"isCommentAutoGenerated" : true
},
"Preview" : {
},
"Preview and test different subscription paywall designs" : {
},
"Preview subscription themes" : {
"comment" : "A description of the paywall preview feature.",
"isCommentAutoGenerated" : true
},
"Privacy Lock" : {
"comment" : "A title for a toggle that controls whether or not biometric authentication is enabled.",
"isCommentAutoGenerated" : true,
@@ -10815,6 +10848,9 @@
"See Your Year at a Glance" : {
"comment" : "A title for a feature that lets users see their year's emotional trends.",
"isCommentAutoGenerated" : true
},
"Select Style" : {
},
"Select this mood" : {
"comment" : "A hint that appears when a user taps on a mood button.",
@@ -11598,6 +11634,10 @@
}
}
},
"Simply\nKnow Yourself" : {
"comment" : "The title of the first section in the Minimal theme marketing content.",
"isCommentAutoGenerated" : true
},
"Skip subscription and complete setup" : {
"comment" : "A button label that says \"Skip subscription and complete setup\". It's used in the \"OnboardingSubscription\" view.",
"isCommentAutoGenerated" : true
@@ -13085,6 +13125,10 @@
}
}
},
"Understand\nYourself Deeper" : {
"comment" : "A headline in the premium subscription marketing content.",
"isCommentAutoGenerated" : true
},
"Unlock AI-Powered Insights" : {
"comment" : "A title for a button that allows users to unlock premium insights.",
"isCommentAutoGenerated" : true,
@@ -13262,6 +13306,10 @@
"comment" : "A button label that appears when the user is not a premium subscriber, encouraging them to subscribe to unlock more features.",
"isCommentAutoGenerated" : true
},
"UNLOCK YOUR\nFULL SIGNAL" : {
"comment" : "A title displayed in the neon marketing content view.",
"isCommentAutoGenerated" : true
},
"Use Siri to Log Moods" : {
"localizations" : {
"de" : {
@@ -13343,6 +13391,9 @@
}
}
}
},
"View Full Paywall" : {
},
"View the app introduction again" : {
"comment" : "A button that allows a user to view the app's introductory screen again.",
@@ -13605,6 +13656,10 @@
}
}
},
"Watch Yourself\nBloom" : {
"comment" : "A title describing the premium subscription experience.",
"isCommentAutoGenerated" : true
},
"Wednesday - 10th" : {
"comment" : "A label displayed above the date of a mood entry.",
"isCommentAutoGenerated" : true,
@@ -14235,6 +14290,10 @@
}
}
},
"Your emotions tell a story.\nPremium helps you read it." : {
"comment" : "A subheadline describing the benefits of the premium subscription.",
"isCommentAutoGenerated" : true
},
"Your Feelings" : {
"comment" : "The title of the main screen in the lock screen.",
"isCommentAutoGenerated" : true

View File

@@ -14,6 +14,7 @@ enum VotingLayoutStyle: Int, CaseIterable {
case stacked = 3 // Full-width vertical list
case aura = 4 // Atmospheric glowing orbs with flowing layout
case orbit = 5 // Celestial orbit with center core
case neon = 6 // Synthwave arcade equalizer with glowing segments
var displayName: String {
switch self {
@@ -23,6 +24,32 @@ enum VotingLayoutStyle: Int, CaseIterable {
case .stacked: return "Stacked"
case .aura: return "Aura"
case .orbit: return "Orbit"
case .neon: return "Neon"
}
}
}
enum PaywallStyle: Int, CaseIterable {
case celestial = 0 // Celestial Self-Discovery - aurora, floating orbs
case garden = 1 // Garden Growth - organic, blooming nature
case neon = 2 // Neon Pulse - synthwave, energetic
case minimal = 3 // Minimal Zen - clean, sophisticated
var displayName: String {
switch self {
case .celestial: return "Celestial"
case .garden: return "Garden"
case .neon: return "Neon"
case .minimal: return "Minimal"
}
}
var description: String {
switch self {
case .celestial: return "Aurora lights & floating emotion orbs"
case .garden: return "Blooming flowers & organic growth"
case .neon: return "Synthwave energy & glowing pulses"
case .minimal: return "Clean typography & subtle elegance"
}
}
}
@@ -105,6 +132,7 @@ class UserDefaultsStore {
case privacyLockEnabled
case healthKitEnabled
case healthKitSyncEnabled
case paywallStyle
case contentViewCurrentSelectedHeaderViewBackDays
case contentViewHeaderTag

View File

@@ -84,6 +84,8 @@ struct AddMoodHeaderView: View {
AuraVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .orbit:
OrbitVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .neon:
NeonVotingView(moodTint: moodTint, onMoodSelected: addItem)
}
}
@@ -549,3 +551,227 @@ struct AddMoodHeaderView_Previews: PreviewProvider {
}
}
}
// MARK: - Layout 7: Neon (Synthwave Arcade Equalizer)
struct NeonVotingView: View {
let moodTint: MoodTints
let onMoodSelected: (Mood) -> Void
@State private var pulsePhase = false
@State private var hoveredMood: Mood?
// Synthwave color palette
private let neonCyan = Color(red: 0.0, green: 1.0, blue: 0.82)
private let neonMagenta = Color(red: 1.0, green: 0.0, blue: 0.8)
private let neonYellow = Color(red: 1.0, green: 0.9, blue: 0.0)
private let deepBlack = Color(red: 0.02, green: 0.02, blue: 0.04)
var body: some View {
ZStack {
// Grid background
neonGridBackground
// Equalizer bars
HStack(spacing: 8) {
ForEach(Mood.allValues, id: \.self) { mood in
NeonEqualizerBar(
mood: mood,
moodTint: moodTint,
isHovered: hoveredMood == mood,
pulsePhase: pulsePhase,
neonCyan: neonCyan,
neonMagenta: neonMagenta,
onTap: { onMoodSelected(mood) }
)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(
LinearGradient(
colors: [neonCyan.opacity(0.6), neonMagenta.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
)
.shadow(color: neonCyan.opacity(0.2), radius: 20, x: 0, y: 0)
.shadow(color: neonMagenta.opacity(0.15), radius: 30, x: 0, y: 10)
.onAppear {
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
pulsePhase = true
}
}
}
private var neonGridBackground: some View {
ZStack {
// Deep black base
deepBlack
// Grid
Canvas { context, size in
let gridSpacing: CGFloat = 20
let cyanColor = Color(red: 0.0, green: 0.8, blue: 0.7)
// Horizontal lines
for y in stride(from: 0, to: size.height, by: gridSpacing) {
var path = Path()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: size.width, y: y))
context.stroke(path, with: .color(cyanColor.opacity(0.08)), lineWidth: 0.5)
}
// Vertical lines
for x in stride(from: 0, to: size.width, by: gridSpacing) {
var path = Path()
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: size.height))
context.stroke(path, with: .color(cyanColor.opacity(0.08)), lineWidth: 0.5)
}
}
// Ambient glow at bottom
LinearGradient(
colors: [
neonMagenta.opacity(0.15),
Color.clear
],
startPoint: .bottom,
endPoint: .top
)
.blur(radius: 30)
}
}
}
struct NeonEqualizerBar: View {
let mood: Mood
let moodTint: MoodTints
let isHovered: Bool
let pulsePhase: Bool
let neonCyan: Color
let neonMagenta: Color
let onTap: () -> Void
@State private var isPressed = false
private var barHeight: CGFloat {
switch mood {
case .great: return 140
case .good: return 115
case .average: return 90
case .bad: return 65
case .horrible: return 45
default: return 90
}
}
private var barColor: Color {
switch mood {
case .great: return neonCyan
case .good: return Color(red: 0.2, green: 1.0, blue: 0.6)
case .average: return Color(red: 1.0, green: 0.9, blue: 0.0)
case .bad: return Color(red: 1.0, green: 0.5, blue: 0.0)
case .horrible: return neonMagenta
default: return Color(red: 1.0, green: 0.9, blue: 0.0)
}
}
var body: some View {
Button(action: onTap) {
VStack(spacing: 8) {
// The equalizer bar
ZStack(alignment: .bottom) {
// Glow background
RoundedRectangle(cornerRadius: 6)
.fill(barColor.opacity(pulsePhase ? 0.15 : 0.08))
.frame(height: barHeight + 20)
.blur(radius: 15)
// Main bar
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [
barColor,
barColor.opacity(0.7)
],
startPoint: .top,
endPoint: .bottom
)
)
.frame(height: isPressed ? barHeight * 0.9 : barHeight)
.shadow(color: barColor.opacity(0.8), radius: pulsePhase ? 12 : 8, x: 0, y: 0)
.shadow(color: barColor.opacity(0.4), radius: pulsePhase ? 20 : 15, x: 0, y: 5)
// Top highlight
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [Color.white.opacity(0.5), Color.clear],
startPoint: .top,
endPoint: .center
)
)
.frame(height: isPressed ? barHeight * 0.9 : barHeight)
// Level indicators (horizontal lines)
VStack(spacing: 8) {
ForEach(0..<Int(barHeight / 15), id: \.self) { _ in
Rectangle()
.fill(Color.black.opacity(0.3))
.frame(height: 2)
}
}
.frame(height: isPressed ? barHeight * 0.9 - 10 : barHeight - 10)
.clipShape(RoundedRectangle(cornerRadius: 4))
.padding(.horizontal, 4)
.padding(.bottom, 5)
}
.frame(maxHeight: 160, alignment: .bottom)
// Mood label
Text(mood.shortLabel)
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(barColor)
.shadow(color: barColor.opacity(0.8), radius: 4, x: 0, y: 0)
}
}
.buttonStyle(NeonBarButtonStyle(isPressed: $isPressed))
.frame(maxWidth: .infinity)
}
}
struct NeonBarButtonStyle: ButtonStyle {
@Binding var isPressed: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
.onChange(of: configuration.isPressed) { _, newValue in
isPressed = newValue
}
}
}
private extension Mood {
var shortLabel: String {
switch self {
case .great: return "GRT"
case .good: return "GUD"
case .average: return "AVG"
case .bad: return "BAD"
case .horrible: return "HRB"
default: return "---"
}
}
}

View File

@@ -587,6 +587,27 @@ struct VotingLayoutPickerCompact: View {
.offset(orbitOffset(index: index, total: 5, radius: 16))
}
}
case .neon:
// Equalizer bars
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.black)
.frame(width: 36, height: 36)
HStack(spacing: 2) {
ForEach(0..<5, id: \.self) { index in
let heights: [CGFloat] = [24, 18, 14, 10, 8]
RoundedRectangle(cornerRadius: 1)
.fill(
LinearGradient(
colors: [Color(red: 0, green: 1, blue: 0.82), Color(red: 1, green: 0, blue: 0.8)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: 4, height: heights[index])
}
}
}
}
}

View File

@@ -151,6 +151,30 @@ struct VotingLayoutPickerView: View {
.offset(orbitOffset(index: index, total: 5, radius: 16))
}
}
case .neon:
// Equalizer bars
ZStack {
// Grid background hint
RoundedRectangle(cornerRadius: 4)
.fill(Color.black)
.frame(width: 36, height: 36)
// Equalizer bars
HStack(spacing: 2) {
ForEach(0..<5, id: \.self) { index in
let heights: [CGFloat] = [24, 18, 14, 10, 8]
RoundedRectangle(cornerRadius: 1)
.fill(
LinearGradient(
colors: [Color(red: 0, green: 1, blue: 0.82), Color(red: 1, green: 0, blue: 0.8)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: 4, height: heights[index])
}
}
}
}
}

View File

@@ -590,103 +590,205 @@ struct EntryListView: View {
.background(colorScheme == .dark ? Color(.systemGray6) : .white)
}
// MARK: - Neon Style (Cyberpunk/Synthwave)
// MARK: - Neon Style (Synthwave Arcade)
private var neonStyle: some View {
ZStack {
// Dark base with scanline effect
RoundedRectangle(cornerRadius: 4)
.fill(Color.black)
let neonCyan = Color(red: 0.0, green: 1.0, blue: 0.82)
let neonMagenta = Color(red: 1.0, green: 0.0, blue: 0.8)
let deepBlack = Color(red: 0.02, green: 0.02, blue: 0.04)
// Scanline overlay
VStack(spacing: 2) {
ForEach(0..<20, id: \.self) { _ in
Rectangle()
.fill(Color.white.opacity(0.03))
.frame(height: 1)
Spacer().frame(height: 3)
// Map mood to synthwave color spectrum
let synthwaveColor: Color = {
switch entry.mood {
case .great: return neonCyan
case .good: return Color(red: 0.0, green: 0.9, blue: 0.6) // Cyan-green
case .average: return Color(red: 0.9, green: 0.9, blue: 0.2) // Neon yellow
case .bad: return Color(red: 1.0, green: 0.5, blue: 0.3) // Neon orange
case .horrible: return neonMagenta
default: return Color(red: 0.9, green: 0.9, blue: 0.2) // Fallback yellow
}
}()
return ZStack {
// Deep black base
RoundedRectangle(cornerRadius: 6)
.fill(deepBlack)
// Grid background pattern
Canvas { context, size in
let gridSpacing: CGFloat = 12
let gridColor = neonCyan.opacity(0.08)
// Horizontal grid lines
for y in stride(from: 0, through: size.height, by: gridSpacing) {
var path = Path()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: size.width, y: y))
context.stroke(path, with: .color(gridColor), lineWidth: 0.5)
}
// Vertical grid lines
for x in stride(from: 0, through: size.width, by: gridSpacing) {
var path = Path()
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: size.height))
context.stroke(path, with: .color(gridColor), lineWidth: 0.5)
}
}
.clipShape(RoundedRectangle(cornerRadius: 4))
.clipShape(RoundedRectangle(cornerRadius: 6))
// Scanline overlay for CRT effect
VStack(spacing: 0) {
ForEach(0..<30, id: \.self) { _ in
Rectangle()
.fill(Color.white.opacity(0.015))
.frame(height: 1)
Spacer().frame(height: 2)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
// Content
HStack(spacing: 16) {
// Neon-outlined mood indicator
// Neon equalizer-style mood indicator
ZStack {
// Glow effect
RoundedRectangle(cornerRadius: 8)
.stroke(isMissing ? Color.gray : moodColor, lineWidth: 2)
// Outer glow ring
RoundedRectangle(cornerRadius: 10)
.stroke(
LinearGradient(
colors: isMissing
? [Color.gray.opacity(0.3)]
: [neonCyan.opacity(0.6), neonMagenta.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 2
)
.blur(radius: 4)
.opacity(0.8)
RoundedRectangle(cornerRadius: 8)
.stroke(isMissing ? Color.gray : moodColor, lineWidth: 2)
// Inner border
RoundedRectangle(cornerRadius: 10)
.stroke(
LinearGradient(
colors: isMissing
? [Color.gray.opacity(0.4)]
: [neonCyan, neonMagenta],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1.5
)
// Mood icon with synthwave glow
imagePack.icon(forMood: entry.mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(isMissing ? .gray : moodColor)
.shadow(color: isMissing ? .clear : moodColor, radius: 8, x: 0, y: 0)
.foregroundColor(isMissing ? .gray : synthwaveColor)
.shadow(color: isMissing ? .clear : synthwaveColor.opacity(0.9), radius: 8, x: 0, y: 0)
.shadow(color: isMissing ? .clear : synthwaveColor.opacity(0.5), radius: 16, x: 0, y: 0)
.accessibilityLabel(entry.mood.strValue)
}
.frame(width: 52, height: 52)
.frame(width: 54, height: 54)
VStack(alignment: .leading, spacing: 6) {
// Date in monospace terminal style
VStack(alignment: .leading, spacing: 5) {
// Date in cyan monospace
Text(entry.forDate, format: .dateTime.year().month(.twoDigits).day(.twoDigits))
.font(.caption.weight(.medium).monospaced())
.foregroundColor(Color(red: 0.4, green: 1.0, blue: 0.4)) // Terminal green
.font(.system(.caption, design: .monospaced).weight(.semibold))
.foregroundColor(neonCyan)
.shadow(color: neonCyan.opacity(0.5), radius: 4, x: 0, y: 0)
if isMissing {
Text("NO_DATA")
.font(.headline.weight(.bold).monospaced())
.foregroundColor(.gray)
.font(.system(.headline, design: .monospaced).weight(.black))
.foregroundColor(.gray.opacity(0.6))
} else {
// Mood in glowing text
// Mood text with synthwave gradient glow
Text(entry.moodString.uppercased())
.font(.headline.weight(.black))
.foregroundColor(moodColor)
.shadow(color: moodColor.opacity(0.8), radius: 6, x: 0, y: 0)
.shadow(color: moodColor.opacity(0.4), radius: 12, x: 0, y: 0)
.font(.system(.headline, design: .monospaced).weight(.black))
.foregroundStyle(
LinearGradient(
colors: [neonCyan, synthwaveColor, neonMagenta],
startPoint: .leading,
endPoint: .trailing
)
)
.shadow(color: synthwaveColor.opacity(0.8), radius: 6, x: 0, y: 0)
.shadow(color: neonMagenta.opacity(0.3), radius: 12, x: 0, y: 0)
}
// Weekday
// Weekday in magenta
Text(entry.forDate, format: .dateTime.weekday(.wide))
.font(.caption2.weight(.medium).monospaced())
.foregroundColor(.white.opacity(0.4))
.font(.system(.caption2, design: .monospaced).weight(.medium))
.foregroundColor(neonMagenta.opacity(0.7))
.textCase(.uppercase)
}
Spacer()
// Chevron with glow
// Equalizer bars indicator (mini visualization)
if !isMissing {
HStack(spacing: 2) {
ForEach(0..<5, id: \.self) { index in
let barHeight: CGFloat = {
let moodIndex = Mood.allValues.firstIndex(of: entry.mood) ?? 2
let heights: [[CGFloat]] = [
[28, 22, 16, 10, 6], // Great
[24, 28, 18, 12, 8], // Good
[16, 20, 28, 20, 16], // Okay
[8, 12, 18, 28, 24], // Bad
[6, 10, 16, 22, 28] // Awful
]
return heights[moodIndex][index]
}()
RoundedRectangle(cornerRadius: 1)
.fill(
LinearGradient(
colors: [neonCyan, neonMagenta],
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: 3, height: barHeight)
.shadow(color: neonCyan.opacity(0.5), radius: 2, x: 0, y: 0)
}
}
.padding(.trailing, 4)
}
// Chevron with gradient glow
Image(systemName: "chevron.right")
.font(.subheadline.weight(.bold))
.foregroundColor(isMissing ? .gray : moodColor)
.shadow(color: isMissing ? .clear : moodColor, radius: 4, x: 0, y: 0)
.foregroundStyle(
isMissing
? AnyShapeStyle(Color.gray.opacity(0.4))
: AnyShapeStyle(LinearGradient(
colors: [neonCyan, neonMagenta],
startPoint: .top,
endPoint: .bottom
))
)
.shadow(color: isMissing ? .clear : neonCyan.opacity(0.5), radius: 4, x: 0, y: 0)
}
.padding(.horizontal, 18)
.padding(.vertical, 16)
.padding(.vertical, 14)
// Neon border
RoundedRectangle(cornerRadius: 4)
// Cyan-to-magenta gradient border
RoundedRectangle(cornerRadius: 6)
.stroke(
LinearGradient(
colors: isMissing
? [Color.gray.opacity(0.3), Color.gray.opacity(0.1)]
: [moodColor.opacity(0.8), moodColor.opacity(0.3)],
? [Color.gray.opacity(0.2), Color.gray.opacity(0.1)]
: [neonCyan.opacity(0.7), neonMagenta.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
}
.shadow(
color: isMissing ? .clear : moodColor.opacity(0.3),
radius: 12,
x: 0,
y: 4
)
// Outer glow effect
.shadow(color: isMissing ? .clear : neonCyan.opacity(0.2), radius: 12, x: 0, y: 2)
.shadow(color: isMissing ? .clear : neonMagenta.opacity(0.15), radius: 20, x: 0, y: 4)
}
// MARK: - Ink Style (Japanese Zen/Calligraphy)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,476 @@
//
// PaywallPreviewSettingsView.swift
// Feels
//
// Debug view for previewing and switching paywall styles.
//
import SwiftUI
#if DEBUG
struct PaywallPreviewSettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedStyle: PaywallStyle = .celestial
@State private var showFullPreview = false
@EnvironmentObject var iapManager: IAPManager
var body: some View {
ScrollView {
VStack(spacing: 24) {
headerSection
stylePicker
previewCard
fullPreviewButton
}
.padding()
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Paywall Styles")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.sheet(isPresented: $showFullPreview) {
FeelsSubscriptionStoreView(style: selectedStyle)
.environmentObject(iapManager)
}
}
private var headerSection: some View {
VStack(spacing: 8) {
Image(systemName: "paintpalette.fill")
.font(.system(size: 40))
.foregroundStyle(
LinearGradient(
colors: [.purple, .pink, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text("Paywall Theme Lab")
.font(.title2.bold())
Text("Preview and test different subscription paywall designs")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.vertical)
}
private var stylePicker: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Select Style")
.font(.headline)
ForEach(PaywallStyle.allCases, id: \.self) { style in
StyleOptionRow(
style: style,
isSelected: selectedStyle == style,
onTap: { selectedStyle = style }
)
}
}
}
private var previewCard: some View {
VStack(spacing: 0) {
// Mini preview header
HStack {
Text("Preview")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Text(selectedStyle.displayName)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(.secondarySystemGroupedBackground))
// Mini preview content
miniPreview
.frame(height: 280)
.clipped()
}
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color(.separator), lineWidth: 0.5)
)
}
@ViewBuilder
private var miniPreview: some View {
switch selectedStyle {
case .celestial:
CelestialMiniPreview()
case .garden:
GardenMiniPreview()
case .neon:
NeonMiniPreview()
case .minimal:
MinimalMiniPreview()
}
}
private var fullPreviewButton: some View {
Button {
showFullPreview = true
} label: {
HStack {
Image(systemName: "arrow.up.left.and.arrow.down.right")
Text("View Full Paywall")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(
LinearGradient(
colors: gradientColors,
startPoint: .leading,
endPoint: .trailing
)
)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private var gradientColors: [Color] {
switch selectedStyle {
case .celestial:
return [Color(red: 1.0, green: 0.4, blue: 0.5), Color(red: 0.6, green: 0.4, blue: 0.9)]
case .garden:
return [Color(red: 0.4, green: 0.75, blue: 0.45), Color(red: 0.3, green: 0.6, blue: 0.4)]
case .neon:
return [Color(red: 0.0, green: 0.9, blue: 0.7), Color(red: 0.9, green: 0.0, blue: 0.7)]
case .minimal:
return [Color(red: 0.85, green: 0.6, blue: 0.5), Color(red: 0.7, green: 0.5, blue: 0.45)]
}
}
}
// MARK: - Style Option Row
struct StyleOptionRow: View {
let style: PaywallStyle
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 14) {
// Style icon
ZStack {
Circle()
.fill(iconGradient)
.frame(width: 44, height: 44)
Image(systemName: iconName)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
// Text
VStack(alignment: .leading, spacing: 2) {
Text(style.displayName)
.font(.body.weight(.medium))
.foregroundColor(.primary)
Text(style.description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
// Selection indicator
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.foregroundColor(.accentColor)
}
}
.padding(12)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
private var iconName: String {
switch style {
case .celestial: return "sparkles"
case .garden: return "leaf.fill"
case .neon: return "bolt.fill"
case .minimal: return "circle.grid.2x2"
}
}
private var iconGradient: LinearGradient {
switch style {
case .celestial:
return LinearGradient(
colors: [Color(red: 1.0, green: 0.4, blue: 0.5), Color(red: 0.6, green: 0.4, blue: 0.9)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .garden:
return LinearGradient(
colors: [Color(red: 0.4, green: 0.75, blue: 0.45), Color(red: 0.3, green: 0.6, blue: 0.4)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .neon:
return LinearGradient(
colors: [Color(red: 0.0, green: 0.9, blue: 0.7), Color(red: 0.9, green: 0.0, blue: 0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .minimal:
return LinearGradient(
colors: [Color(red: 0.85, green: 0.6, blue: 0.5), Color(red: 0.7, green: 0.5, blue: 0.45)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
}
// MARK: - Mini Previews
struct CelestialMiniPreview: View {
@State private var animate = false
var body: some View {
ZStack {
// Background
LinearGradient(
colors: [
Color(red: 0.05, green: 0.05, blue: 0.12),
Color(red: 0.08, green: 0.06, blue: 0.15)
],
startPoint: .top,
endPoint: .bottom
)
// Glow
Circle()
.fill(Color(red: 1.0, green: 0.4, blue: 0.5).opacity(0.3))
.frame(width: 200, height: 200)
.blur(radius: 60)
.offset(y: animate ? -20 : 0)
// Content
VStack(spacing: 12) {
// Mini orbs
HStack(spacing: -10) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(orbColors[i])
.frame(width: 24, height: 24)
.shadow(color: orbColors[i].opacity(0.5), radius: 8)
}
}
Text("Understand\nYourself Deeper")
.font(.system(size: 18, weight: .bold, design: .serif))
.multilineTextAlignment(.center)
.foregroundColor(.white)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
animate = true
}
}
}
private var orbColors: [Color] {
[
Color(red: 1.0, green: 0.8, blue: 0.3),
Color(red: 1.0, green: 0.5, blue: 0.5),
Color(red: 0.6, green: 0.5, blue: 0.9)
]
}
}
struct GardenMiniPreview: View {
@State private var bloom = false
var body: some View {
ZStack {
// Background
LinearGradient(
colors: [
Color(red: 0.05, green: 0.12, blue: 0.08),
Color(red: 0.08, green: 0.18, blue: 0.1)
],
startPoint: .top,
endPoint: .bottom
)
// Glow
Circle()
.fill(Color(red: 0.3, green: 0.7, blue: 0.4).opacity(0.25))
.frame(width: 200, height: 200)
.blur(radius: 60)
// Content
VStack(spacing: 12) {
// Mini flower
ZStack {
ForEach(0..<6, id: \.self) { i in
Ellipse()
.fill(Color(red: 1.0, green: 0.6, blue: 0.7))
.frame(width: 14, height: bloom ? 28 : 20)
.offset(y: bloom ? -22 : -16)
.rotationEffect(.degrees(Double(i) * 60))
}
Circle()
.fill(Color(red: 1.0, green: 0.9, blue: 0.6))
.frame(width: 20, height: 20)
}
Text("Watch Yourself\nBloom")
.font(.system(size: 18, weight: .bold, design: .serif))
.multilineTextAlignment(.center)
.foregroundColor(.white)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
bloom = true
}
}
}
}
struct NeonMiniPreview: View {
@State private var pulse = false
var body: some View {
ZStack {
// Background
Color(red: 0.02, green: 0.02, blue: 0.05)
// Grid
Canvas { context, size in
let spacing: CGFloat = 20
for y in stride(from: 0, to: size.height, by: spacing) {
var path = Path()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: size.width, y: y))
context.stroke(path, with: .color(Color.cyan.opacity(0.1)), lineWidth: 0.5)
}
}
// Glows
Circle()
.fill(Color.cyan.opacity(pulse ? 0.3 : 0.15))
.frame(width: 150, height: 150)
.blur(radius: 50)
.offset(y: -40)
Circle()
.fill(Color.pink.opacity(pulse ? 0.2 : 0.1))
.frame(width: 120, height: 120)
.blur(radius: 40)
.offset(x: 30, y: 40)
// Content
VStack(spacing: 12) {
// Neon ring
Circle()
.stroke(
LinearGradient(
colors: [.cyan, .pink],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 3
)
.frame(width: 50, height: 50)
.shadow(color: .cyan.opacity(0.5), radius: pulse ? 15 : 8)
Text("UNLOCK YOUR\nFULL SIGNAL")
.font(.system(size: 14, weight: .black, design: .monospaced))
.multilineTextAlignment(.center)
.foregroundStyle(
LinearGradient(colors: [.cyan, .pink], startPoint: .leading, endPoint: .trailing)
)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
pulse = true
}
}
}
}
struct MinimalMiniPreview: View {
@State private var breathe = false
var body: some View {
ZStack {
// Background
LinearGradient(
colors: [
Color(red: 0.98, green: 0.96, blue: 0.92),
Color(red: 0.95, green: 0.93, blue: 0.88)
],
startPoint: .top,
endPoint: .bottom
)
// Content
VStack(spacing: 16) {
// Breathing circles
ZStack {
Circle()
.stroke(Color(red: 0.8, green: 0.7, blue: 0.6).opacity(0.3), lineWidth: 1)
.frame(width: breathe ? 60 : 50, height: breathe ? 60 : 50)
Circle()
.fill(Color(red: 0.95, green: 0.6, blue: 0.5).opacity(0.4))
.frame(width: 30, height: 30)
.scaleEffect(breathe ? 1.1 : 0.95)
}
Text("Simply\nKnow Yourself")
.font(.system(size: 18, weight: .light, design: .serif))
.italic()
.multilineTextAlignment(.center)
.foregroundColor(Color(red: 0.2, green: 0.15, blue: 0.1))
}
}
.onAppear {
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
breathe = true
}
}
}
}
#Preview {
NavigationStack {
PaywallPreviewSettingsView()
.environmentObject(IAPManager())
}
}
#endif

View File

@@ -54,6 +54,7 @@ struct SettingsContentView: View {
debugSectionHeader
trialDateButton
animationLabButton
paywallPreviewButton
#endif
Spacer()
@@ -245,6 +246,7 @@ struct SettingsContentView: View {
}
@State private var showAnimationLab = false
@State private var showPaywallPreview = false
private var animationLabButton: some View {
ZStack {
@@ -284,6 +286,51 @@ struct SettingsContentView: View {
}
}
}
private var paywallPreviewButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
Button {
showPaywallPreview = true
} label: {
HStack(spacing: 12) {
Image(systemName: "paintpalette.fill")
.font(.title2)
.foregroundStyle(
LinearGradient(
colors: [.purple, .pink, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Paywall Styles")
.foregroundColor(textColor)
Text("Preview subscription themes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding()
}
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
.sheet(isPresented: $showPaywallPreview) {
NavigationStack {
PaywallPreviewSettingsView()
}
}
}
#endif
// MARK: - Privacy Lock Toggle