// // ReflectTips.swift // Reflect // // Custom tips system for feature discovery and onboarding // import SwiftUI // MARK: - ReflectTip Protocol @MainActor protocol ReflectTip: 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: ReflectTip { 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: ReflectTip { 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 { ReflectTipsManager.shared.moodLogCount >= 7 } } @MainActor struct SiriShortcutTip: ReflectTip { let id = "siriShortcut" let title = "Use Siri to Log Moods" let message = "Say \"Hey Siri, log my mood as great in Reflect\" for hands-free logging." let icon = "mic.fill" var isEligible: Bool { ReflectTipsManager.shared.moodLogCount >= 3 } } @MainActor struct HealthKitSyncTip: ReflectTip { 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 { ReflectTipsManager.shared.hasSeenSettings } } @MainActor struct WidgetVotingTip: ReflectTip { 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 { ReflectTipsManager.shared.daysUsingApp >= 2 } } @MainActor struct TimeViewTip: ReflectTip { 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: ReflectTip { 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 { ReflectTipsManager.shared.currentStreak >= 3 } } // MARK: - All Tips @MainActor enum ReflectTips { 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 ReflectTipsManager: ObservableObject { static let shared = ReflectTipsManager() // MARK: - Keys private enum Keys { static let tipsEnabled = "reflect.tips.enabled" static let shownTipIDs = "reflect.tips.shownIDs" static let moodLogCount = "reflect.tips.moodLogCount" static let hasSeenSettings = "reflect.tips.hasSeenSettings" static let daysUsingApp = "reflect.tips.daysUsingApp" static let currentStreak = "reflect.tips.currentStreak" } // MARK: - Published State @Published var currentTip: (any ReflectTip)? @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 ReflectTip) -> 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 ReflectTip) { guard shouldShowTip(tip) else { return } currentTip = tip showTipModal = true } /// Mark a tip as shown (called when dismissed) func markTipAsShown(_ tip: any ReflectTip) { 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 ReflectTipModifier: ViewModifier { private enum AnimationConstants { static let tipPresentationDelay: TimeInterval = 0.5 } let tip: any ReflectTip let gradientColors: [Color] // Use local state for sheet to avoid interference from other manager state changes @State private var showSheet = false @State private var hasCheckedEligibility = false func body(content: Content) -> some View { content .onAppear { // Only check eligibility once per view lifetime guard !hasCheckedEligibility else { return } hasCheckedEligibility = true // Delay tip presentation to ensure view hierarchy is fully established // This prevents "presenting from detached view controller" errors DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.tipPresentationDelay) { if ReflectTipsManager.shared.shouldShowTip(tip) { showSheet = true } } } .sheet(isPresented: $showSheet) { TipModalView( icon: tip.icon, title: tip.title, message: tip.message, gradientColors: gradientColors, onDismiss: { showSheet = false ReflectTipsManager.shared.markTipAsShown(tip) } ) .presentationDetents([.height(340)]) .presentationDragIndicator(.visible) .presentationCornerRadius(28) } } } extension View { /// Attach a tip that shows as a themed modal when eligible func reflectTip(_ tip: any ReflectTip, gradientColors: [Color]) -> some View { modifier(ReflectTipModifier(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 { reflectTip(ReflectTips.customizeLayout, gradientColors: [Color(hex: "667eea"), Color(hex: "764ba2")]) } func aiInsightsTip() -> some View { reflectTip(ReflectTips.aiInsights, gradientColors: [.purple, .blue]) } func siriShortcutTip() -> some View { reflectTip(ReflectTips.siriShortcut, gradientColors: [Color(hex: "f093fb"), Color(hex: "f5576c")]) } func healthKitSyncTip() -> some View { reflectTip(ReflectTips.healthKitSync, gradientColors: [.red, .pink]) } func widgetVotingTip() -> some View { reflectTip(ReflectTips.widgetVoting, gradientColors: [Color(hex: "11998e"), Color(hex: "38ef7d")]) } func timeViewTip() -> some View { reflectTip(ReflectTips.timeView, gradientColors: [.blue, .cyan]) } func moodStreakTip() -> some View { reflectTip(ReflectTips.moodStreak, gradientColors: [.orange, .red]) } }