// // FeelsTips.swift // Feels // // Custom tips system for feature discovery and onboarding // import SwiftUI // MARK: - FeelsTip Protocol @MainActor protocol FeelsTip: Identifiable { var id: String { get } var title: String { get } var message: String { get } var icon: String { get } var isEligible: Bool { get } } // MARK: - Tip Definitions @MainActor struct CustomizeLayoutTip: FeelsTip { let id = "customizeLayout" let title = "Personalize Your Experience" let message = "Customize mood icons, colors, and layouts to make the app truly yours." let icon = "paintbrush.fill" var isEligible: Bool { true } } @MainActor struct AIInsightsTip: FeelsTip { let id = "aiInsights" let title = "Discover AI Insights" let message = "Get personalized insights about your mood patterns powered by Apple Intelligence." let icon = "brain.head.profile" var isEligible: Bool { FeelsTipsManager.shared.moodLogCount >= 7 } } @MainActor struct SiriShortcutTip: FeelsTip { let id = "siriShortcut" let title = "Use Siri to Log Moods" let message = "Say \"Hey Siri, log my mood as great in Feels\" for hands-free logging." let icon = "mic.fill" var isEligible: Bool { FeelsTipsManager.shared.moodLogCount >= 3 } } @MainActor struct HealthKitSyncTip: FeelsTip { let id = "healthKitSync" let title = "Sync with Apple Health" let message = "Connect to Apple Health to see your mood data alongside sleep, exercise, and more." let icon = "heart.fill" var isEligible: Bool { FeelsTipsManager.shared.hasSeenSettings } } @MainActor struct WidgetVotingTip: FeelsTip { let id = "widgetVoting" let title = "Vote from Your Home Screen" let message = "Add the Mood Vote widget to quickly log your mood without opening the app." let icon = "square.grid.2x2.fill" var isEligible: Bool { FeelsTipsManager.shared.daysUsingApp >= 2 } } @MainActor struct TimeViewTip: FeelsTip { let id = "timeView" let title = "View Your History" let message = "Switch between Day, Month, and Year views to see your mood patterns over time." let icon = "calendar" var isEligible: Bool { true } } @MainActor struct MoodStreakTip: FeelsTip { let id = "moodStreak" let title = "Build Your Streak!" let message = "Log your mood daily to build a streak. Consistency helps you understand your patterns." let icon = "flame.fill" var isEligible: Bool { FeelsTipsManager.shared.currentStreak >= 3 } } // MARK: - All Tips @MainActor enum FeelsTips { static let customizeLayout = CustomizeLayoutTip() static let aiInsights = AIInsightsTip() static let siriShortcut = SiriShortcutTip() static let healthKitSync = HealthKitSyncTip() static let widgetVoting = WidgetVotingTip() static let timeView = TimeViewTip() static let moodStreak = MoodStreakTip() } // MARK: - Tips Manager @MainActor class FeelsTipsManager: ObservableObject { static let shared = FeelsTipsManager() // MARK: - Keys private enum Keys { static let tipsEnabled = "feels.tips.enabled" static let shownTipIDs = "feels.tips.shownIDs" static let moodLogCount = "feels.tips.moodLogCount" static let hasSeenSettings = "feels.tips.hasSeenSettings" static let daysUsingApp = "feels.tips.daysUsingApp" static let currentStreak = "feels.tips.currentStreak" } // MARK: - Published State @Published var currentTip: (any FeelsTip)? @Published var showTipModal = false // MARK: - Global Toggle (configurable) var tipsEnabled: Bool { get { UserDefaults.standard.bool(forKey: Keys.tipsEnabled) } set { UserDefaults.standard.set(newValue, forKey: Keys.tipsEnabled) } } // MARK: - Session Tracking private(set) var hasShownTipThisSession = false // MARK: - Shown Tips (persisted) private var shownTipIDs: Set { get { let array = UserDefaults.standard.stringArray(forKey: Keys.shownTipIDs) ?? [] return Set(array) } set { UserDefaults.standard.set(Array(newValue), forKey: Keys.shownTipIDs) } } // MARK: - Tip Parameters (persisted) var moodLogCount: Int { get { UserDefaults.standard.integer(forKey: Keys.moodLogCount) } set { UserDefaults.standard.set(newValue, forKey: Keys.moodLogCount) } } var hasSeenSettings: Bool { get { UserDefaults.standard.bool(forKey: Keys.hasSeenSettings) } set { UserDefaults.standard.set(newValue, forKey: Keys.hasSeenSettings) } } var daysUsingApp: Int { get { UserDefaults.standard.integer(forKey: Keys.daysUsingApp) } set { UserDefaults.standard.set(newValue, forKey: Keys.daysUsingApp) } } var currentStreak: Int { get { UserDefaults.standard.integer(forKey: Keys.currentStreak) } set { UserDefaults.standard.set(newValue, forKey: Keys.currentStreak) } } // MARK: - Initialization private init() { // Set default value for tipsEnabled if not set if UserDefaults.standard.object(forKey: Keys.tipsEnabled) == nil { tipsEnabled = true } } // MARK: - Public API /// Check if a tip should be shown func shouldShowTip(_ tip: any FeelsTip) -> Bool { guard tipsEnabled else { return false } guard !hasShownTipThisSession else { return false } guard !shownTipIDs.contains(tip.id) else { return false } guard tip.isEligible else { return false } return true } /// Show a tip if eligible func showTipIfEligible(_ tip: any FeelsTip) { guard shouldShowTip(tip) else { return } currentTip = tip showTipModal = true } /// Mark a tip as shown (called when dismissed) func markTipAsShown(_ tip: any FeelsTip) { shownTipIDs.insert(tip.id) hasShownTipThisSession = true currentTip = nil showTipModal = false } /// Reset session flag (call on app launch) func resetSession() { hasShownTipThisSession = false } /// Reset all tips (for testing) func resetAllTips() { shownTipIDs = [] hasShownTipThisSession = false moodLogCount = 0 hasSeenSettings = false daysUsingApp = 0 currentStreak = 0 } // MARK: - Event Handlers func onMoodLogged() { moodLogCount += 1 } func onSettingsViewed() { hasSeenSettings = true } func updateDaysUsingApp(_ days: Int) { daysUsingApp = days } func updateStreak(_ streak: Int) { currentStreak = streak } } // MARK: - View Modifier for Easy Integration struct FeelsTipModifier: ViewModifier { let tip: any FeelsTip let gradientColors: [Color] @ObservedObject private var tipsManager = FeelsTipsManager.shared func body(content: Content) -> some View { content .onAppear { tipsManager.showTipIfEligible(tip) } .sheet(isPresented: $tipsManager.showTipModal) { if let currentTip = tipsManager.currentTip { TipModalView( icon: currentTip.icon, title: currentTip.title, message: currentTip.message, gradientColors: gradientColors, onDismiss: { tipsManager.markTipAsShown(currentTip) } ) .presentationDetents([.height(340)]) .presentationDragIndicator(.visible) .presentationCornerRadius(28) } } } } extension View { /// Attach a tip that shows as a themed modal when eligible func feelsTip(_ tip: any FeelsTip, gradientColors: [Color]) -> some View { modifier(FeelsTipModifier(tip: tip, gradientColors: gradientColors)) } // MARK: - Convenience Modifiers /// Default gradient for tips private var defaultTipGradient: [Color] { [Color(hex: "667eea"), Color(hex: "764ba2")] } func customizeLayoutTip() -> some View { feelsTip(FeelsTips.customizeLayout, gradientColors: [Color(hex: "667eea"), Color(hex: "764ba2")]) } func aiInsightsTip() -> some View { feelsTip(FeelsTips.aiInsights, gradientColors: [.purple, .blue]) } func siriShortcutTip() -> some View { feelsTip(FeelsTips.siriShortcut, gradientColors: [Color(hex: "f093fb"), Color(hex: "f5576c")]) } func healthKitSyncTip() -> some View { feelsTip(FeelsTips.healthKitSync, gradientColors: [.red, .pink]) } func widgetVotingTip() -> some View { feelsTip(FeelsTips.widgetVoting, gradientColors: [Color(hex: "11998e"), Color(hex: "38ef7d")]) } func timeViewTip() -> some View { feelsTip(FeelsTips.timeView, gradientColors: [.blue, .cyan]) } func moodStreakTip() -> some View { feelsTip(FeelsTips.moodStreak, gradientColors: [.orange, .red]) } }