Add 12 cohesive app themes with matching subscription and lock screens

- Create AppTheme enum bundling color tint, icon pack, entry style, voting layout, paywall style, and lock screen style into unified themes
- Add AppThemePickerView for selecting themes with preview cards and detail sheets
- Extend PaywallStyle to 12 variants (celestial, garden, neon, minimal, zen, editorial, mixtape, heartfelt, luxe, forecast, playful, journal)
- Add LockScreenStyle enum with 13 variants including aurora default
- Create themed subscription paywalls with unique backgrounds, decorative elements, and typography for each style
- Create themed lock screens with unique backgrounds, central elements, and unlock buttons
- Update FeelsSubscriptionStoreView to read style from AppStorage so it auto-matches current theme
- Update PaywallPreviewSettingsView to support all 12 paywall styles

🤖 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:53:35 -06:00
parent 53eb953b77
commit a0b30d8bae
8 changed files with 4084 additions and 77 deletions

View File

@@ -558,8 +558,37 @@
"comment" : "A symbol that appears before a command in a terminal interface.",
"isCommentAutoGenerated" : true
},
"✨" : {
},
"🌈" : {
},
"🎉" : {
"comment" : "A smiling face emoji.",
"isCommentAutoGenerated" : true
},
"🎨" : {
"comment" : "An emoji used as a preview for a theme in the CustomizeContentView.",
"isCommentAutoGenerated" : true
},
"💫" : {
},
"😄" : {
"comment" : "A smiling face emoji.",
"isCommentAutoGenerated" : true
},
"😊" : {
"comment" : "A playful emoji used in the lock screen view.",
"isCommentAutoGenerated" : true
},
"12" : {
},
"12 curated combinations of colors, icons, and layouts" : {
"comment" : "A description of the 12 color, icon, and layout combinations available in the theme picker.",
"isCommentAutoGenerated" : true
},
"17" : {
@@ -1190,8 +1219,8 @@
"comment" : "An accessibility label for the section of the settings view that indicates that Apple Health is not available on the user's device.",
"isCommentAutoGenerated" : true
},
"are safe here" : {
"comment" : "A description of the safety of the app's \"Aurora background\".",
"Apply %@ Theme" : {
"comment" : "A button that, when tapped, applies a theme to the app. The label inside the button changes to reflect the name of the theme being applied.",
"isCommentAutoGenerated" : true
},
"Are you sure you want to delete this mood entry? This cannot be undone." : {
@@ -1321,10 +1350,6 @@
}
}
},
"Authenticate to continue your journey" : {
"comment" : "A description of the action required to unlock the app.",
"isCommentAutoGenerated" : true
},
"Authentication Failed" : {
"comment" : "An alert title when authentication fails.",
"isCommentAutoGenerated" : true,
@@ -1367,6 +1392,10 @@
}
}
},
"Browse Themes" : {
"comment" : "A suggestion to the user to explore different color and layout combinations.",
"isCommentAutoGenerated" : true
},
"Build Your Streak!" : {
"comment" : "A title for a tip that encourages users to build a mood streak.",
"isCommentAutoGenerated" : true,
@@ -1787,6 +1816,10 @@
}
}
},
"Choose Your Vibe" : {
"comment" : "A title displayed at the top of the view, encouraging the user to choose a theme.",
"isCommentAutoGenerated" : true
},
"Clarity through simplicity.\nPremium unlocks understanding." : {
"comment" : "A description of the benefits of the premium subscription.",
"isCommentAutoGenerated" : true
@@ -4506,6 +4539,10 @@
"comment" : "A hint that appears when tapping on an icon to select it.",
"isCommentAutoGenerated" : true
},
"Each theme combines colors, icons, layouts, and styles into a cohesive experience." : {
"comment" : "A description of how themes work.",
"isCommentAutoGenerated" : true
},
"Edit" : {
"comment" : "A button label that triggers the editing of a journal note.",
"isCommentAutoGenerated" : true,
@@ -4548,6 +4585,13 @@
}
}
},
"Elevate Your\nEmotional Life" : {
},
"ELEVATE YOUR\nEXPERIENCE" : {
"comment" : "A title for the section that describes the premium theme.",
"isCommentAutoGenerated" : true
},
"Entries" : {
"comment" : "A label describing the number of mood entries.",
"isCommentAutoGenerated" : true,
@@ -4674,10 +4718,18 @@
}
}
},
"Every entry tells your story.\nPremium unlocks the full narrative." : {
"comment" : "A description of the premium feature, emphasizing the narrative aspect of journaling.",
"isCommentAutoGenerated" : true
},
"Every feeling is a seed.\nPremium helps you grow." : {
"comment" : "A description of the premium subscription's benefits.",
"isCommentAutoGenerated" : true
},
"Every feeling is a track.\nPremium gives you the full album." : {
"comment" : "A description of the premium subscription that highlights the unlimited access to all tracks.",
"isCommentAutoGenerated" : true
},
"Exit" : {
"comment" : "A button label that dismisses the current view.",
"isCommentAutoGenerated" : true,
@@ -5025,6 +5077,13 @@
}
}
}
},
"Feel It All" : {
"comment" : "The title of a section that highlights the premium feature of the app.",
"isCommentAutoGenerated" : true
},
"Feel With\nAll Your Heart" : {
},
"Feels" : {
"comment" : "The name of the widget.",
@@ -5068,6 +5127,10 @@
}
}
},
"FEELS" : {
"comment" : "The name of the app.",
"isCommentAutoGenerated" : true
},
"Feels Icon" : {
"comment" : "Name of the widget configuration.",
"isCommentAutoGenerated" : true,
@@ -5153,6 +5216,10 @@
}
}
},
"FEELS MIXTAPE" : {
"comment" : "The name of the premium subscription experience.",
"isCommentAutoGenerated" : true
},
"filter_view_total" : {
"extractionState" : "manual",
"localizations" : {
@@ -5199,6 +5266,13 @@
}
}
}
},
"Find Your\nInner Calm" : {
"comment" : "A title describing the main benefit of the premium subscription.",
"isCommentAutoGenerated" : true
},
"Find Your\nInner Peace" : {
},
"Fix Weekday" : {
"comment" : "A button label that, when tapped, will attempt to correct the user's calendar entries that are set to the wrong weekday.",
@@ -5933,6 +6007,10 @@
"comment" : "A description of the social proof badge.",
"isCommentAutoGenerated" : true
},
"JOURNAL" : {
"comment" : "The title of the journal.",
"isCommentAutoGenerated" : true
},
"Journal Note" : {
"comment" : "The title of the view that appears in the navigation bar.",
"isCommentAutoGenerated" : true,
@@ -6099,6 +6177,14 @@
}
}
},
"Level up your self-awareness.\nPremium = MAX POWER!" : {
"comment" : "A tagline describing the premium subscription and its benefits.",
"isCommentAutoGenerated" : true
},
"Like ink on paper, each mood\nleaves its mark. Premium reveals the pattern." : {
"comment" : "A description of how the premium version reveals patterns in user moods.",
"isCommentAutoGenerated" : true
},
"Log Mood" : {
"comment" : "A button that opens Feels to log a mood.",
"isCommentAutoGenerated" : true,
@@ -6349,6 +6435,9 @@
}
}
}
},
"Make Tracking\nFun Again!" : {
},
"Manage" : {
"comment" : "A button that allows the user to manage their subscription.",
@@ -6736,6 +6825,10 @@
}
}
},
"MOOD UNLOCKED! 🎮" : {
"comment" : "A catchy title for the premium subscription content.",
"isCommentAutoGenerated" : true
},
"Mood Vote" : {
"comment" : "The display name of the widget configuration.",
"isCommentAutoGenerated" : true,
@@ -8933,6 +9026,10 @@
}
}
},
"Predict your patterns.\nPrepare for any weather." : {
"comment" : "A description of the premium feature, \"Your Emotional Forecast\", that appears below the title.",
"isCommentAutoGenerated" : true
},
"Premium Active" : {
"localizations" : {
"de" : {
@@ -8973,6 +9070,10 @@
}
}
},
"Premium Edition" : {
"comment" : "A title for the premium edition of the app.",
"isCommentAutoGenerated" : true
},
"Premium Feature" : {
"comment" : "A label indicating a premium feature.",
"isCommentAutoGenerated" : true,
@@ -9019,6 +9120,10 @@
"comment" : "A description of a premium feature that requires a subscription.",
"isCommentAutoGenerated" : true
},
"Premium refinement for those\nwho expect the finest." : {
"comment" : "A description of the premium theme, highlighting its exclusivity and superior quality.",
"isCommentAutoGenerated" : true
},
"Preview" : {
},
@@ -12538,6 +12643,24 @@
}
}
}
},
"THE" : {
"comment" : "The first word of the title of the magazine.",
"isCommentAutoGenerated" : true
},
"THE ART\nOF FEELING" : {
},
"Themes" : {
"comment" : "The title of the view where they can choose different themes.",
"isCommentAutoGenerated" : true
},
"Themes set all four options at once" : {
"comment" : "An explanatory note about how themes affect all four settings.",
"isCommentAutoGenerated" : true
},
"This theme includes" : {
},
"Tip: \"Yesterday\" works great for evening reminders" : {
"comment" : "A tip displayed below the \"Today\" and \"Yesterday\" options in the onboarding flow.",
@@ -13217,6 +13340,10 @@
}
}
},
"Unlock the complete story\nof your emotional landscape." : {
"comment" : "A description of the premium edition of the app.",
"isCommentAutoGenerated" : true
},
"Unlock the Full\nExperience" : {
"comment" : "A title displayed in the onboarding view, promoting the full experience that can be unlocked with a subscription.",
"isCommentAutoGenerated" : true,
@@ -13995,6 +14122,9 @@
}
}
}
},
"Write Your\nEmotional Story" : {
},
"Year in Review" : {
"comment" : "A description below the year's mood breakdown.",
@@ -14080,6 +14210,10 @@
}
}
},
"You can still customize individual settings after applying a theme" : {
"comment" : "A note explaining that users can still customize individual settings after applying a theme.",
"isCommentAutoGenerated" : true
},
"You don't have a streak yet. Log your mood today to start one!" : {
"comment" : "Text displayed in a notification when a user has not logged a mood for the current day.",
"isCommentAutoGenerated" : true,
@@ -14290,12 +14424,24 @@
}
}
},
"Your Emotional\nForecast" : {
"comment" : "The title of the section that describes the main feature of the premium subscription.",
"isCommentAutoGenerated" : true
},
"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.",
"Your heart knows the way.\nPremium helps you listen." : {
"comment" : "A description of the premium subscription service, emphasizing its ability to help users listen to their hearts.",
"isCommentAutoGenerated" : true
},
"YOUR MOOD\nMIXTAPE" : {
"comment" : "The title of the mixtape theme's main content.",
"isCommentAutoGenerated" : true
},
"Your Personal\nDiary" : {
"comment" : "The title of the premium subscription feature.",
"isCommentAutoGenerated" : true
}
},

View File

@@ -0,0 +1,295 @@
//
// AppTheme.swift
// Feels (iOS)
//
// Created by Claude Code on 12/26/24.
//
import SwiftUI
/// Cohesive themes that bundle colors, icons, entry styles, and voting layouts
/// into unified aesthetic experiences. Each theme is designed around a specific
/// emotional resonance and target user persona.
enum AppTheme: Int, CaseIterable, Identifiable {
case zenGarden = 0
case synthwave = 1
case celestial = 2
case editorial = 3
case mixtape = 4
case bloom = 5
case heartfelt = 6
case minimal = 7
case luxe = 8
case forecast = 9
case playful = 10
case journal = 11
var id: Int { rawValue }
// MARK: - Display Properties
var name: String {
switch self {
case .zenGarden: return "Zen Garden"
case .synthwave: return "Synthwave"
case .celestial: return "Celestial"
case .editorial: return "Editorial"
case .mixtape: return "Mixtape"
case .bloom: return "Bloom"
case .heartfelt: return "Heartfelt"
case .minimal: return "Minimal"
case .luxe: return "Luxe"
case .forecast: return "Forecast"
case .playful: return "Playful"
case .journal: return "Journal"
}
}
var tagline: String {
switch self {
case .zenGarden: return "Meditative calm"
case .synthwave: return "Retro-futuristic energy"
case .celestial: return "Cosmic wisdom"
case .editorial: return "Literary elegance"
case .mixtape: return "Analog nostalgia"
case .bloom: return "Growth & healing"
case .heartfelt: return "Emotional depth"
case .minimal: return "Pure simplicity"
case .luxe: return "Premium refinement"
case .forecast: return "Mood as weather"
case .playful: return "Fun & vibrant"
case .journal: return "Personal diary"
}
}
var description: String {
switch self {
case .zenGarden:
return "Japanese minimalism meets mindful awareness. Soft pastels, organic growth icons, brush-stroke entries, and contemplative vertical voting."
case .synthwave:
return "80s arcade aesthetic with neon glow. Electric colors, cosmic icons, grid backgrounds, and equalizer-bar voting."
case .celestial:
return "Journey from void to starlight. Moon phases, orbital layouts, and planetary arrangements for cosmic mood tracking."
case .editorial:
return "Magazine-quality typography and layout. Clean icons, pull-quote entries, and sophisticated presentation."
case .mixtape:
return "Cassette culture and analog warmth. Tape reels, track numbers, and the tactile feel of pressing play."
case .bloom:
return "From wilted flower to full bloom. Organic shapes, glowing orbs, and the gentle metaphor of growth."
case .heartfelt:
return "Unashamed emotional expression. Heart icons from broken to sparkling, bold colors, intuitive selection."
case .minimal:
return "Only the essentials. Clean typography, flat design, and zero distractions."
case .luxe:
return "Liquid glass and premium materials. Cutting-edge iOS design language for the discerning user."
case .forecast:
return "Your mood is the weather. Storm to sunshine icons, flowing wave entries, and natural intuition."
case .playful:
return "Life's too short to be serious. Vibrant neons, familiar emoji, and game-like interaction."
case .journal:
return "Like writing in a physical diary. Stacked paper notes, handwritten feel, and intimate reflection."
}
}
var emoji: String {
switch self {
case .zenGarden: return "🧘"
case .synthwave: return "🌆"
case .celestial: return ""
case .editorial: return "📰"
case .mixtape: return "📼"
case .bloom: return "🌸"
case .heartfelt: return "💖"
case .minimal: return ""
case .luxe: return "💎"
case .forecast: return "🌦️"
case .playful: return "🎮"
case .journal: return "📒"
}
}
// MARK: - Theme Components
var colorTint: MoodTints {
switch self {
case .zenGarden: return .Pastel
case .synthwave: return .Neon
case .celestial: return .Default
case .editorial: return .Pastel
case .mixtape: return .Default
case .bloom: return .Pastel
case .heartfelt: return .Pastel
case .minimal: return .Pastel
case .luxe: return .Default
case .forecast: return .Default
case .playful: return .Neon
case .journal: return .Default
}
}
var iconPack: MoodImages {
switch self {
case .zenGarden: return .Garden
case .synthwave: return .Cosmic
case .celestial: return .Cosmic
case .editorial: return .FontAwesome
case .mixtape: return .Emoji
case .bloom: return .Garden
case .heartfelt: return .Hearts
case .minimal: return .FontAwesome
case .luxe: return .Cosmic
case .forecast: return .Weather
case .playful: return .Emoji
case .journal: return .FontAwesome
}
}
var entryStyle: DayViewStyle {
switch self {
case .zenGarden: return .ink
case .synthwave: return .neon
case .celestial: return .orbit
case .editorial: return .chronicle
case .mixtape: return .tape
case .bloom: return .morph
case .heartfelt: return .bubble
case .minimal: return .minimal
case .luxe: return .glass
case .forecast: return .wave
case .playful: return .pattern
case .journal: return .stack
}
}
var votingLayout: VotingLayoutStyle {
switch self {
case .zenGarden: return .stacked
case .synthwave: return .neon
case .celestial: return .orbit
case .editorial: return .horizontal
case .mixtape: return .cards
case .bloom: return .aura
case .heartfelt: return .radial
case .minimal: return .horizontal
case .luxe: return .aura
case .forecast: return .radial
case .playful: return .cards
case .journal: return .stacked
}
}
var paywallStyle: PaywallStyle {
switch self {
case .zenGarden: return .zen
case .synthwave: return .neon
case .celestial: return .celestial
case .editorial: return .editorial
case .mixtape: return .mixtape
case .bloom: return .garden
case .heartfelt: return .heartfelt
case .minimal: return .minimal
case .luxe: return .luxe
case .forecast: return .forecast
case .playful: return .playful
case .journal: return .journal
}
}
var lockScreenStyle: LockScreenStyle {
switch self {
case .zenGarden: return .zen
case .synthwave: return .neon
case .celestial: return .celestial
case .editorial: return .editorial
case .mixtape: return .mixtape
case .bloom: return .bloom
case .heartfelt: return .heartfelt
case .minimal: return .minimal
case .luxe: return .luxe
case .forecast: return .forecast
case .playful: return .playful
case .journal: return .journal
}
}
// MARK: - Preview Colors (for theme picker UI)
var previewColors: [Color] {
switch self {
case .zenGarden:
return [Color(hex: "#C1E1C1"), Color(hex: "#A7C7E7"), Color(hex: "#fdfd96")]
case .synthwave:
return [Color(red: 0, green: 1, blue: 0.82), Color(red: 1, green: 0, blue: 0.8), Color(hex: "#050510")]
case .celestial:
return [Color(hex: "#0b84ff"), Color(hex: "#31d158"), Color(hex: "#1a1a2e")]
case .editorial:
return [Color(hex: "#A7C7E7"), Color(hex: "#2c2c2c"), Color(hex: "#f5f5f5")]
case .mixtape:
return [Color(hex: "#8B4513"), Color(hex: "#D2691E"), Color(hex: "#2c2c2c")]
case .bloom:
return [Color(hex: "#C1E1C1"), Color(hex: "#ffb347"), Color(hex: "#FF6961")]
case .heartfelt:
return [Color(hex: "#FF6961"), Color(hex: "#ffb347"), Color(hex: "#C1E1C1")]
case .minimal:
return [Color(hex: "#A7C7E7"), Color(hex: "#e0e0e0"), Color(hex: "#f8f8f8")]
case .luxe:
return [Color(hex: "#0b84ff"), Color(hex: "#31d158"), Color.white.opacity(0.8)]
case .forecast:
return [Color(hex: "#0b84ff"), Color(hex: "#ffd709"), Color(hex: "#ff453a")]
case .playful:
return [Color(hex: "#39FF14"), Color(hex: "#FFF01F"), Color(hex: "#FF5F1F")]
case .journal:
return [Color(hex: "#D2B48C"), Color(hex: "#8B4513"), Color(hex: "#FFFEF0")]
}
}
// MARK: - Apply Theme
/// Applies all theme settings to UserDefaults
func apply() {
// Set color tint
GroupUserDefaults.groupDefaults.set(colorTint.rawValue, forKey: UserDefaultsStore.Keys.moodTint.rawValue)
// Set icon pack
GroupUserDefaults.groupDefaults.set(iconPack.rawValue, forKey: UserDefaultsStore.Keys.moodImages.rawValue)
// Set entry style
GroupUserDefaults.groupDefaults.set(entryStyle.rawValue, forKey: UserDefaultsStore.Keys.dayViewStyle.rawValue)
// Set voting layout
GroupUserDefaults.groupDefaults.set(votingLayout.rawValue, forKey: UserDefaultsStore.Keys.votingLayoutStyle.rawValue)
// Set paywall style
GroupUserDefaults.groupDefaults.set(paywallStyle.rawValue, forKey: UserDefaultsStore.Keys.paywallStyle.rawValue)
// Set lock screen style
GroupUserDefaults.groupDefaults.set(lockScreenStyle.rawValue, forKey: UserDefaultsStore.Keys.lockScreenStyle.rawValue)
// Log the theme change
EventLogger.log(event: "apply_theme", withData: ["theme": name])
}
}
// MARK: - Theme Categories for Browsing
extension AppTheme {
enum Category: String, CaseIterable {
case calm = "Calm & Mindful"
case energetic = "Energetic & Bold"
case sophisticated = "Sophisticated"
case expressive = "Expressive"
var themes: [AppTheme] {
switch self {
case .calm:
return [.zenGarden, .minimal, .bloom]
case .energetic:
return [.synthwave, .playful, .mixtape]
case .sophisticated:
return [.celestial, .editorial, .luxe]
case .expressive:
return [.heartfelt, .forecast, .journal]
}
}
}
}

View File

@@ -34,6 +34,14 @@ enum PaywallStyle: Int, CaseIterable {
case garden = 1 // Garden Growth - organic, blooming nature
case neon = 2 // Neon Pulse - synthwave, energetic
case minimal = 3 // Minimal Zen - clean, sophisticated
case zen = 4 // Zen Garden - ink brushstrokes, meditation
case editorial = 5 // Editorial - magazine typography
case mixtape = 6 // Mixtape - cassette/retro analog
case heartfelt = 7 // Heartfelt - hearts and emotion
case luxe = 8 // Luxe - premium glass materials
case forecast = 9 // Forecast - weather metaphors
case playful = 10 // Playful - vibrant emoji patterns
case journal = 11 // Journal - handwritten paper
var displayName: String {
switch self {
@@ -41,6 +49,14 @@ enum PaywallStyle: Int, CaseIterable {
case .garden: return "Garden"
case .neon: return "Neon"
case .minimal: return "Minimal"
case .zen: return "Zen"
case .editorial: return "Editorial"
case .mixtape: return "Mixtape"
case .heartfelt: return "Heartfelt"
case .luxe: return "Luxe"
case .forecast: return "Forecast"
case .playful: return "Playful"
case .journal: return "Journal"
}
}
@@ -50,6 +66,48 @@ enum PaywallStyle: Int, CaseIterable {
case .garden: return "Blooming flowers & organic growth"
case .neon: return "Synthwave energy & glowing pulses"
case .minimal: return "Clean typography & subtle elegance"
case .zen: return "Ink brushstrokes & meditative calm"
case .editorial: return "Magazine typography & literary elegance"
case .mixtape: return "Cassette tapes & analog nostalgia"
case .heartfelt: return "Hearts & emotional expression"
case .luxe: return "Premium glass & refined materials"
case .forecast: return "Weather metaphors & natural flow"
case .playful: return "Vibrant patterns & playful energy"
case .journal: return "Handwritten notes & paper textures"
}
}
}
enum LockScreenStyle: Int, CaseIterable {
case aurora = 0 // Default - emotional aurora with breathing orb
case zen = 1 // Zen - ink circles, minimal, calming
case neon = 2 // Neon - synthwave grid, glowing elements
case celestial = 3 // Celestial - stars, moon phases, cosmic
case editorial = 4 // Editorial - typography focused, clean
case mixtape = 5 // Mixtape - cassette aesthetic, retro
case bloom = 6 // Bloom - organic shapes, flowers
case heartfelt = 7 // Heartfelt - hearts, soft gradients
case minimal = 8 // Minimal - ultra clean, simple
case luxe = 9 // Luxe - glass, premium materials
case forecast = 10 // Forecast - weather, atmospheric
case playful = 11 // Playful - patterns, vibrant
case journal = 12 // Journal - paper, handwritten feel
var displayName: String {
switch self {
case .aurora: return "Aurora"
case .zen: return "Zen"
case .neon: return "Neon"
case .celestial: return "Celestial"
case .editorial: return "Editorial"
case .mixtape: return "Mixtape"
case .bloom: return "Bloom"
case .heartfelt: return "Heartfelt"
case .minimal: return "Minimal"
case .luxe: return "Luxe"
case .forecast: return "Forecast"
case .playful: return "Playful"
case .journal: return "Journal"
}
}
}
@@ -133,6 +191,7 @@ class UserDefaultsStore {
case healthKitEnabled
case healthKitSyncEnabled
case paywallStyle
case lockScreenStyle
case contentViewCurrentSelectedHeaderViewBackDays
case contentViewHeaderTag

View File

@@ -15,6 +15,8 @@ struct CustomizeContentView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@State private var showThemePicker = false
var body: some View {
ScrollView {
VStack(spacing: 24) {
@@ -22,6 +24,50 @@ struct CustomizeContentView: View {
TipView(CustomizeLayoutTip())
.tipBackground(Color(.secondarySystemBackground))
// QUICK THEMES
SettingsSection(title: "Quick Start") {
Button(action: { showThemePicker = true }) {
HStack(spacing: 16) {
// Emoji preview
ZStack {
LinearGradient(
colors: [.purple.opacity(0.8), .blue.opacity(0.8), .cyan.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Text("🎨")
.font(.title)
}
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 4) {
Text("Browse Themes")
.font(.headline)
.foregroundColor(.primary)
Text("12 curated combinations of colors, icons, and layouts")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
Spacer()
Image(systemName: "chevron.right")
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
}
.padding(12)
.background(colorScheme == .dark ? Color(.systemGray6) : .white)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)
}
.sheet(isPresented: $showThemePicker) {
AppThemePickerView()
}
// APPEARANCE
SettingsSection(title: "Appearance") {
VStack(spacing: 16) {

View File

@@ -0,0 +1,414 @@
//
// AppThemePickerView.swift
// Feels (iOS)
//
// Created by Claude Code on 12/26/24.
//
import SwiftUI
struct AppThemePickerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults)
private var moodTint: Int = 0
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults)
private var moodImages: Int = 0
@AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults)
private var dayViewStyle: Int = 0
@AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults)
private var votingLayoutStyle: Int = 0
@State private var selectedTheme: AppTheme?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 32) {
// Header
headerSection
// Theme Grid
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 16),
GridItem(.flexible(), spacing: 16)
], spacing: 20) {
ForEach(AppTheme.allCases) { theme in
AppThemeCard(
theme: theme,
isSelected: isThemeActive(theme),
onTap: { selectTheme(theme) }
)
}
}
.padding(.horizontal, 20)
// Footer note
footerNote
.padding(.bottom, 40)
}
}
.background(colorScheme == .dark ? Color.black : Color(.systemGroupedBackground))
.navigationTitle("Themes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.sheet(item: $selectedTheme) { theme in
AppThemePreviewSheet(theme: theme) {
applyTheme(theme)
}
}
}
}
// MARK: - Subviews
private var headerSection: some View {
VStack(spacing: 12) {
Text("Choose Your Vibe")
.font(.system(.title, design: .rounded, weight: .bold))
.foregroundColor(.primary)
Text("Each theme combines colors, icons, layouts, and styles into a cohesive experience.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.top, 20)
}
private var footerNote: some View {
VStack(spacing: 8) {
Text("Themes set all four options at once")
.font(.caption)
.foregroundColor(.secondary)
Text("You can still customize individual settings after applying a theme")
.font(.caption2)
.foregroundColor(.secondary.opacity(0.7))
}
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
// MARK: - Logic
private func isThemeActive(_ theme: AppTheme) -> Bool {
return moodTint == theme.colorTint.rawValue &&
moodImages == theme.iconPack.rawValue &&
dayViewStyle == theme.entryStyle.rawValue &&
votingLayoutStyle == theme.votingLayout.rawValue
}
private func selectTheme(_ theme: AppTheme) {
selectedTheme = theme
}
private func applyTheme(_ theme: AppTheme) {
withAnimation(.easeInOut(duration: 0.3)) {
theme.apply()
selectedTheme = nil
}
}
}
// MARK: - Theme Card
struct AppThemeCard: View {
let theme: AppTheme
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
VStack(spacing: 0) {
// Preview area
ZStack {
// Background gradient
LinearGradient(
colors: theme.previewColors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
// Theme emoji
Text(theme.emoji)
.font(.system(size: 44))
.shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2)
// Selected checkmark
if isSelected {
VStack {
HStack {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.white)
.background(
Circle()
.fill(.green)
.frame(width: 28, height: 28)
)
.padding(8)
}
Spacer()
}
}
}
.frame(height: 100)
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: 16,
bottomLeadingRadius: 0,
bottomTrailingRadius: 0,
topTrailingRadius: 16
)
)
// Info area
VStack(alignment: .leading, spacing: 4) {
Text(theme.name)
.font(.headline)
.foregroundColor(.primary)
.lineLimit(1)
Text(theme.tagline)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(colorScheme == .dark ? Color(.systemGray6) : .white)
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: 0,
bottomLeadingRadius: 16,
bottomTrailingRadius: 16,
topTrailingRadius: 0
)
)
}
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 3)
)
.shadow(
color: colorScheme == .dark ? .clear : .black.opacity(0.08),
radius: 8,
x: 0,
y: 4
)
}
.buttonStyle(.plain)
}
}
// MARK: - Theme Preview Sheet
struct AppThemePreviewSheet: View {
let theme: AppTheme
let onApply: () -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Hero
heroSection
// What's included
componentsSection
// Apply button
applyButton
.padding(.bottom, 40)
}
}
.background(colorScheme == .dark ? Color.black : Color(.systemGroupedBackground))
.navigationTitle(theme.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
private var heroSection: some View {
ZStack {
// Gradient background
LinearGradient(
colors: theme.previewColors + [theme.previewColors[0].opacity(0.5)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(spacing: 16) {
Text(theme.emoji)
.font(.system(size: 72))
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
Text(theme.tagline)
.font(.title3.weight(.medium))
.foregroundColor(.white)
.shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2)
}
}
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 20))
.padding(.horizontal, 20)
.padding(.top, 16)
}
private var componentsSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("This theme includes")
.font(.headline)
.padding(.horizontal, 20)
VStack(spacing: 12) {
ThemeComponentRow(
icon: "paintpalette.fill",
title: "Colors",
value: theme.colorTint == .Default ? "Default" :
theme.colorTint == .Neon ? "Neon" :
theme.colorTint == .Pastel ? "Pastel" : "Custom",
color: .orange
)
ThemeComponentRow(
icon: "face.smiling.fill",
title: "Icons",
value: iconName(for: theme.iconPack),
color: .purple
)
ThemeComponentRow(
icon: "rectangle.stack.fill",
title: "Entry Style",
value: theme.entryStyle.displayName,
color: .blue
)
ThemeComponentRow(
icon: "hand.tap.fill",
title: "Voting Layout",
value: theme.votingLayout.displayName,
color: .green
)
}
.padding(.horizontal, 20)
// Description
Text(theme.description)
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.horizontal, 20)
.padding(.top, 8)
}
}
private var applyButton: some View {
Button(action: {
onApply()
dismiss()
}) {
HStack {
Image(systemName: "paintbrush.fill")
Text("Apply \(theme.name) Theme")
}
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
colors: [theme.previewColors[0], theme.previewColors[1]],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: theme.previewColors[0].opacity(0.4), radius: 8, x: 0, y: 4)
}
.padding(.horizontal, 20)
}
private func iconName(for pack: MoodImages) -> String {
switch pack {
case .FontAwesome: return "Classic Faces"
case .Emoji: return "Emoji"
case .HandEmjoi: return "Hand Gestures"
case .Weather: return "Weather"
case .Garden: return "Garden"
case .Hearts: return "Hearts"
case .Cosmic: return "Cosmic"
}
}
}
// MARK: - Component Row
struct ThemeComponentRow: View {
let icon: String
let title: String
let value: String
let color: Color
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(color)
.frame(width: 36, height: 36)
.background(color.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(title)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.subheadline.weight(.medium))
.foregroundColor(.primary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(colorScheme == .dark ? Color(.systemGray6) : .white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Preview
struct AppThemePickerView_Previews: PreviewProvider {
static var previews: some View {
AppThemePickerView()
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,22 @@ struct PaywallPreviewSettingsView: View {
NeonMiniPreview()
case .minimal:
MinimalMiniPreview()
case .zen:
ZenMiniPreview()
case .editorial:
EditorialMiniPreview()
case .mixtape:
MixtapeMiniPreview()
case .heartfelt:
HeartfeltMiniPreview()
case .luxe:
LuxeMiniPreview()
case .forecast:
ForecastMiniPreview()
case .playful:
PlayfulMiniPreview()
case .journal:
JournalMiniPreview()
}
}
@@ -156,6 +172,22 @@ struct PaywallPreviewSettingsView: View {
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)]
case .zen:
return [Color(red: 0.6, green: 0.7, blue: 0.6), Color(red: 0.5, green: 0.6, blue: 0.55)]
case .editorial:
return [Color(red: 0.15, green: 0.15, blue: 0.15), Color(red: 0.3, green: 0.3, blue: 0.3)]
case .mixtape:
return [Color(red: 0.95, green: 0.45, blue: 0.35), Color(red: 0.95, green: 0.65, blue: 0.25)]
case .heartfelt:
return [Color(red: 0.9, green: 0.45, blue: 0.55), Color(red: 0.95, green: 0.6, blue: 0.65)]
case .luxe:
return [Color(red: 0.75, green: 0.6, blue: 0.35), Color(red: 0.55, green: 0.45, blue: 0.25)]
case .forecast:
return [Color(red: 0.4, green: 0.65, blue: 0.85), Color(red: 0.3, green: 0.5, blue: 0.75)]
case .playful:
return [Color(red: 0.95, green: 0.55, blue: 0.35), Color(red: 0.95, green: 0.75, blue: 0.35)]
case .journal:
return [Color(red: 0.55, green: 0.45, blue: 0.35), Color(red: 0.4, green: 0.35, blue: 0.3)]
}
}
}
@@ -218,6 +250,14 @@ struct StyleOptionRow: View {
case .garden: return "leaf.fill"
case .neon: return "bolt.fill"
case .minimal: return "circle.grid.2x2"
case .zen: return "circle"
case .editorial: return "textformat"
case .mixtape: return "opticaldisc.fill"
case .heartfelt: return "heart.fill"
case .luxe: return "diamond.fill"
case .forecast: return "cloud.fill"
case .playful: return "face.smiling.fill"
case .journal: return "book.closed.fill"
}
}
@@ -247,6 +287,54 @@ struct StyleOptionRow: View {
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .zen:
return LinearGradient(
colors: [Color(red: 0.6, green: 0.7, blue: 0.6), Color(red: 0.5, green: 0.6, blue: 0.55)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .editorial:
return LinearGradient(
colors: [Color(red: 0.15, green: 0.15, blue: 0.15), Color(red: 0.3, green: 0.3, blue: 0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .mixtape:
return LinearGradient(
colors: [Color(red: 0.95, green: 0.45, blue: 0.35), Color(red: 0.95, green: 0.65, blue: 0.25)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .heartfelt:
return LinearGradient(
colors: [Color(red: 0.9, green: 0.45, blue: 0.55), Color(red: 0.95, green: 0.6, blue: 0.65)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .luxe:
return LinearGradient(
colors: [Color(red: 0.75, green: 0.6, blue: 0.35), Color(red: 0.55, green: 0.45, blue: 0.25)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .forecast:
return LinearGradient(
colors: [Color(red: 0.4, green: 0.65, blue: 0.85), Color(red: 0.3, green: 0.5, blue: 0.75)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .playful:
return LinearGradient(
colors: [Color(red: 0.95, green: 0.55, blue: 0.35), Color(red: 0.95, green: 0.75, blue: 0.35)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
case .journal:
return LinearGradient(
colors: [Color(red: 0.55, green: 0.45, blue: 0.35), Color(red: 0.4, green: 0.35, blue: 0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
}
@@ -467,6 +555,340 @@ struct MinimalMiniPreview: View {
}
}
struct ZenMiniPreview: View {
@State private var breathe = false
var body: some View {
ZStack {
// Warm paper background
LinearGradient(
colors: [
Color(red: 0.96, green: 0.94, blue: 0.90),
Color(red: 0.92, green: 0.90, blue: 0.86)
],
startPoint: .top,
endPoint: .bottom
)
// Content
VStack(spacing: 16) {
// Enso circle
Circle()
.stroke(
Color(red: 0.3, green: 0.35, blue: 0.3),
style: StrokeStyle(lineWidth: 3, lineCap: .round)
)
.frame(width: breathe ? 55 : 50, height: breathe ? 55 : 50)
.rotationEffect(.degrees(-30))
Text("Find Your\nInner Peace")
.font(.system(size: 18, weight: .light, design: .serif))
.multilineTextAlignment(.center)
.foregroundColor(Color(red: 0.25, green: 0.25, blue: 0.2))
}
}
.onAppear {
withAnimation(.easeInOut(duration: 4).repeatForever(autoreverses: true)) {
breathe = true
}
}
}
}
struct EditorialMiniPreview: View {
var body: some View {
ZStack {
// Deep black background
Color.black
// Content
VStack(spacing: 16) {
// Simple geometric element
Rectangle()
.fill(Color.white)
.frame(width: 40, height: 2)
Text("THE ART\nOF FEELING")
.font(.system(size: 16, weight: .bold, design: .serif))
.tracking(3)
.multilineTextAlignment(.center)
.foregroundColor(.white)
Rectangle()
.fill(Color.white)
.frame(width: 40, height: 2)
}
}
}
}
struct MixtapeMiniPreview: View {
@State private var spin = false
var body: some View {
ZStack {
// Warm gradient background
LinearGradient(
colors: [
Color(red: 0.95, green: 0.45, blue: 0.35),
Color(red: 0.95, green: 0.65, blue: 0.25)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
// Content
VStack(spacing: 12) {
// Mini cassette
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.black.opacity(0.8))
.frame(width: 50, height: 32)
HStack(spacing: 10) {
Circle()
.fill(Color.white.opacity(0.9))
.frame(width: 14, height: 14)
.rotationEffect(.degrees(spin ? 360 : 0))
Circle()
.fill(Color.white.opacity(0.9))
.frame(width: 14, height: 14)
.rotationEffect(.degrees(spin ? 360 : 0))
}
}
Text("YOUR MOOD\nMIXTAPE")
.font(.system(size: 14, weight: .black))
.multilineTextAlignment(.center)
.foregroundColor(.white)
}
}
.onAppear {
withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
spin = true
}
}
}
}
struct HeartfeltMiniPreview: View {
@State private var beat = false
var body: some View {
ZStack {
// Soft pink gradient
LinearGradient(
colors: [
Color(red: 1.0, green: 0.95, blue: 0.95),
Color(red: 0.98, green: 0.9, blue: 0.92)
],
startPoint: .top,
endPoint: .bottom
)
// Content
VStack(spacing: 12) {
// Floating hearts
HStack(spacing: -8) {
ForEach(0..<3, id: \.self) { i in
Image(systemName: "heart.fill")
.font(.system(size: 20 - CGFloat(i * 4)))
.foregroundColor(Color(red: 0.9, green: 0.45, blue: 0.55))
.scaleEffect(beat ? 1.1 : 0.95)
}
}
Text("Feel With\nAll Your Heart")
.font(.system(size: 17, weight: .medium, design: .serif))
.italic()
.multilineTextAlignment(.center)
.foregroundColor(Color(red: 0.4, green: 0.25, blue: 0.3))
}
}
.onAppear {
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
beat = true
}
}
}
}
struct LuxeMiniPreview: View {
@State private var shimmer = false
var body: some View {
ZStack {
// Deep rich background
LinearGradient(
colors: [
Color(red: 0.12, green: 0.1, blue: 0.08),
Color(red: 0.08, green: 0.06, blue: 0.04)
],
startPoint: .top,
endPoint: .bottom
)
// Content
VStack(spacing: 14) {
// Diamond icon
Image(systemName: "diamond.fill")
.font(.system(size: 36))
.foregroundStyle(
LinearGradient(
colors: [
Color(red: 0.85, green: 0.7, blue: 0.45),
Color(red: 0.65, green: 0.5, blue: 0.3)
],
startPoint: shimmer ? .topLeading : .bottomTrailing,
endPoint: shimmer ? .bottomTrailing : .topLeading
)
)
Text("Elevate Your\nEmotional Life")
.font(.system(size: 16, weight: .light, design: .serif))
.tracking(1)
.multilineTextAlignment(.center)
.foregroundColor(Color(red: 0.85, green: 0.8, blue: 0.7))
}
}
.onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
shimmer = true
}
}
}
}
struct ForecastMiniPreview: View {
@State private var drift = false
var body: some View {
ZStack {
// Sky gradient
LinearGradient(
colors: [
Color(red: 0.55, green: 0.75, blue: 0.95),
Color(red: 0.4, green: 0.6, blue: 0.85)
],
startPoint: .top,
endPoint: .bottom
)
// Floating clouds
HStack(spacing: 20) {
Image(systemName: "cloud.fill")
.font(.system(size: 28))
.foregroundColor(.white.opacity(0.8))
.offset(x: drift ? 5 : -5)
Image(systemName: "sun.max.fill")
.font(.system(size: 24))
.foregroundColor(Color(red: 1.0, green: 0.85, blue: 0.4))
}
.offset(y: -30)
// Content
VStack(spacing: 8) {
Text("Your Emotional\nForecast")
.font(.system(size: 17, weight: .semibold, design: .rounded))
.multilineTextAlignment(.center)
.foregroundColor(.white)
}
.offset(y: 30)
}
.onAppear {
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
drift = true
}
}
}
}
struct PlayfulMiniPreview: View {
@State private var bounce = false
var body: some View {
ZStack {
// Warm playful gradient
LinearGradient(
colors: [
Color(red: 1.0, green: 0.98, blue: 0.94),
Color(red: 0.98, green: 0.95, blue: 0.9)
],
startPoint: .top,
endPoint: .bottom
)
// Bouncing emojis
HStack(spacing: 8) {
Text("😊")
.font(.system(size: 28))
.offset(y: bounce ? -8 : 0)
Text("🎉")
.font(.system(size: 24))
.offset(y: bounce ? 0 : -8)
Text("")
.font(.system(size: 20))
.offset(y: bounce ? -8 : 0)
}
.offset(y: -30)
// Content
VStack(spacing: 8) {
Text("Make Tracking\nFun Again!")
.font(.system(size: 17, weight: .bold, design: .rounded))
.multilineTextAlignment(.center)
.foregroundColor(Color(red: 0.3, green: 0.25, blue: 0.2))
}
.offset(y: 35)
}
.onAppear {
withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) {
bounce = true
}
}
}
}
struct JournalMiniPreview: View {
var body: some View {
ZStack {
// Paper texture background
LinearGradient(
colors: [
Color(red: 0.95, green: 0.92, blue: 0.88),
Color(red: 0.92, green: 0.88, blue: 0.82)
],
startPoint: .top,
endPoint: .bottom
)
// Horizontal lines like notebook paper
VStack(spacing: 18) {
ForEach(0..<6, id: \.self) { _ in
Rectangle()
.fill(Color(red: 0.7, green: 0.65, blue: 0.6).opacity(0.3))
.frame(height: 1)
}
}
.padding(.horizontal, 30)
// Content
VStack(spacing: 12) {
Image(systemName: "book.closed.fill")
.font(.system(size: 32))
.foregroundColor(Color(red: 0.5, green: 0.4, blue: 0.35))
Text("Write Your\nEmotional Story")
.font(.system(size: 16, weight: .medium, design: .serif))
.italic()
.multilineTextAlignment(.center)
.foregroundColor(Color(red: 0.35, green: 0.3, blue: 0.25))
}
}
}
}
#Preview {
NavigationStack {
PaywallPreviewSettingsView()