From c59f215535d82abc9f93043371338e4de5240ffc Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 28 Dec 2025 21:33:36 -0600 Subject: [PATCH] Replace TipKit with custom themed tips modal system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TipModalView with gradient header, themed styling, and spring animations - Create FeelsTipsManager with global toggle, session tracking, and persistence - Define FeelsTip protocol and convert all 7 tips to new system - Add convenience view modifiers (.customizeLayoutTip(), .aiInsightsTip(), etc.) - Remove TipKit dependency from all views - Add Tips Preview debug screen in Settings to test all tip modals - Update documentation for new custom tips system 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Feels/Localizable.xcstrings | 68 +++ Shared/FeelsApp.swift | 5 +- Shared/FeelsTips.swift | 400 +++++++++++------- Shared/MoodLogger.swift | 6 +- .../Views/CustomizeView/CustomizeView.swift | 6 +- Shared/Views/DayView/DayView.swift | 1 - Shared/Views/InsightsView/InsightsView.swift | 1 - Shared/Views/SettingsView/SettingsView.swift | 50 ++- Shared/Views/TipModalView.swift | 380 +++++++++++++++++ docs/Apple-Features.md | 36 +- docs/TipKit-Tips.md | 78 ++-- 11 files changed, 809 insertions(+), 222 deletions(-) create mode 100644 Shared/Views/TipModalView.swift diff --git a/Feels/Localizable.xcstrings b/Feels/Localizable.xcstrings index 9800328..a099f3a 100644 --- a/Feels/Localizable.xcstrings +++ b/Feels/Localizable.xcstrings @@ -767,6 +767,7 @@ }, "Add Feels to Control Center for one-tap mood logging from anywhere." : { "comment" : "A tip message for adding the Feels app to the Control Center for easy access to mood logging.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -897,6 +898,7 @@ }, "Add the Mood Vote widget to quickly log your mood without opening the app." : { "comment" : "A message encouraging users to add the Mood Vote widget to their home screen to log moods without using the main app.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -1402,6 +1404,7 @@ }, "Build Your Streak!" : { "comment" : "A title for a tip that encourages users to build a mood streak.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -1881,6 +1884,7 @@ }, "Connect to Apple Health to see your mood data alongside sleep, exercise, and more." : { "comment" : "A message that encourages connecting their Apple Health account to see more data about their health and moods.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -3027,6 +3031,14 @@ } } }, + "Current Parameters" : { + "comment" : "A section header that lists various current settings and statistics of the app.", + "isCommentAutoGenerated" : true + }, + "Current Streak" : { + "comment" : "A label for the current streak of using the feature.", + "isCommentAutoGenerated" : true + }, "Current: %@" : { "comment" : "A text view displaying the current date and time of the first app launch.", "isCommentAutoGenerated" : true, @@ -3603,6 +3615,10 @@ } } }, + "Days Using App" : { + "comment" : "A label describing the number of days the app has been used.", + "isCommentAutoGenerated" : true + }, "Debug" : { "comment" : "A section header in the settings view, hidden in release builds.", "isCommentAutoGenerated" : true, @@ -4298,6 +4314,7 @@ }, "Discover AI Insights" : { "comment" : "A tip title for a feature that provides personalized insights about mood patterns.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -5505,6 +5522,7 @@ }, "Get personalized insights about your mood patterns powered by Apple Intelligence." : { "comment" : "A message accompanying the \"Discover AI Insights\" tip, encouraging users to explore their mood patterns with Apple Intelligence.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -5630,6 +5648,10 @@ } } }, + "Got it" : { + "comment" : "A button label that says \"Got it\".", + "isCommentAutoGenerated" : true + }, "Got it! Logged %@ for today." : { "comment" : "A confirmation dialog and a snippet view that appear when the \"Log Mood\" intent is triggered. The argument is the name of the mood that was logged.", "isCommentAutoGenerated" : true, @@ -5672,6 +5694,14 @@ } } }, + "Green dot = eligible to show. Tips only show once per session when eligible." : { + "comment" : "A footer label explaining that tips are only shown once per session and that the green dot indicates whether a tip is currently eligible to be shown.", + "isCommentAutoGenerated" : true + }, + "Has Seen Settings" : { + "comment" : "A label for whether the user has seen the settings screen.", + "isCommentAutoGenerated" : true + }, "How are you feeling?" : { }, @@ -6335,6 +6365,7 @@ }, "Log your mood daily to build a streak. Consistency helps you understand your patterns." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -6757,6 +6788,10 @@ } } }, + "Mood Log Count" : { + "comment" : "A label describing the count of mood logs.", + "isCommentAutoGenerated" : true + }, "Mood Logged" : { "comment" : "A title for the view that appears when a user logs their mood.", "isCommentAutoGenerated" : true, @@ -8800,6 +8835,7 @@ }, "Personalize Your Experience" : { "comment" : "A title for a tip that encourages users to customize their mood tracking experience.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -9906,6 +9942,7 @@ }, "Quick Access from Control Center" : { "comment" : "A tip that instructs users to add the Feels app to the Control Center for quick access to mood logging.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -10248,6 +10285,10 @@ "comment" : "A hint that describes the purpose of the Privacy Lock toggle.", "isCommentAutoGenerated" : true }, + "Reset All Tips" : { + "comment" : "A button that resets all tips to their default state.", + "isCommentAutoGenerated" : true + }, "Reset luanch date to current date" : { "comment" : "A button label that resets the app's launch date to the current date.", "isCommentAutoGenerated" : true, @@ -10893,6 +10934,7 @@ }, "Say \"Hey Siri, log my mood as great in Feels\" for hands-free logging." : { "comment" : "A tip message for using Siri to log moods.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -11683,6 +11725,10 @@ } } }, + "Shown This Session" : { + "comment" : "A label showing whether the tip has been shown during the current session.", + "isCommentAutoGenerated" : true + }, "SIDE A" : { "comment" : "The label for the left side of the tape reel.", "isCommentAutoGenerated" : true, @@ -12189,6 +12235,7 @@ }, "Switch between Day, Month, and Year views to see your mood patterns over time." : { "comment" : "A tip that instructs the user to switch between different time views to view their mood history.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -12235,6 +12282,7 @@ }, "Sync with Apple Health" : { "comment" : "A tip to sync their data with Apple Health.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -12365,6 +12413,7 @@ }, "Tap here to customize mood icons, colors, and layouts." : { "comment" : "A message accompanying the \"Personalize Your Experience\" tip, encouraging users to customize their mood tracking interface.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -12539,6 +12588,10 @@ "comment" : "A hint that describes the action to subscribe to the widget.", "isCommentAutoGenerated" : true }, + "Tap to preview" : { + "comment" : "A text label displayed above a list of tips, instructing the user to tap on them to view more information.", + "isCommentAutoGenerated" : true + }, "Tap to record your mood for this day" : { "comment" : "A description of what a user can do to add a new entry to their mood journal.", "isCommentAutoGenerated" : true, @@ -12732,6 +12785,14 @@ } } }, + "Tips Enabled" : { + "comment" : "A toggle that enables or disables tips in the app.", + "isCommentAutoGenerated" : true + }, + "Tips Preview" : { + "comment" : "A label for a view that previews all tip modals.", + "isCommentAutoGenerated" : true + }, "Today" : { "comment" : "A label displayed next to the icon representing today's mood.", "isCommentAutoGenerated" : true, @@ -13466,6 +13527,7 @@ "isCommentAutoGenerated" : true }, "Use Siri to Log Moods" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -13547,6 +13609,10 @@ } } }, + "View all tip modals" : { + "comment" : "A description of what the \"Tips Preview\" button does.", + "isCommentAutoGenerated" : true + }, "View Full Paywall" : { }, @@ -13556,6 +13622,7 @@ }, "View Your History" : { "comment" : "A tip title for viewing and managing one's mood history.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -13645,6 +13712,7 @@ }, "Vote from Your Home Screen" : { "comment" : "A tip encouraging users to vote on their mood from their home screen.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift index 0822e76..0203181 100644 --- a/Shared/FeelsApp.swift +++ b/Shared/FeelsApp.swift @@ -9,7 +9,6 @@ import SwiftUI import SwiftData import BackgroundTasks import WidgetKit -import TipKit @main struct FeelsApp: App { @@ -30,8 +29,8 @@ struct FeelsApp: App { } UNUserNotificationCenter.current().setBadgeCount(0) - // Configure TipKit - TipsManager.shared.configure() + // Reset tips session on app launch + FeelsTipsManager.shared.resetSession() // Initialize Live Activity scheduler LiveActivityScheduler.shared.scheduleBasedOnCurrentTime() diff --git a/Shared/FeelsTips.swift b/Shared/FeelsTips.swift index fa7590b..c4c5e82 100644 --- a/Shared/FeelsTips.swift +++ b/Shared/FeelsTips.swift @@ -2,231 +2,309 @@ // FeelsTips.swift // Feels // -// TipKit implementation for feature discovery and onboarding +// Custom tips system for feature discovery and onboarding // -import TipKit 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 -/// Tip for customizing mood layouts -struct CustomizeLayoutTip: Tip { - var title: Text { - Text("Personalize Your Experience") - } +@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 } +} - var message: Text? { - Text("Tap here to customize mood icons, colors, and layouts.") - } - - var image: Image? { - Image(systemName: "paintbrush") +@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 } } -/// Tip for AI Insights feature -struct AIInsightsTip: Tip { - var title: Text { - Text("Discover AI Insights") - } - - var message: Text? { - Text("Get personalized insights about your mood patterns powered by Apple Intelligence.") - } - - var image: Image? { - Image(systemName: "brain") - } - - var rules: [Rule] { - #Rule(Self.$hasLoggedMoods) { $0 >= 7 } - } - - @Parameter - static var hasLoggedMoods: Int = 0 -} - -/// Tip for Siri shortcuts -struct SiriShortcutTip: Tip { - var title: Text { - Text("Use Siri to Log Moods") - } - - var message: Text? { - Text("Say \"Hey Siri, log my mood as great in Feels\" for hands-free logging.") - } - - var image: Image? { - Image(systemName: "mic.fill") - } - - var rules: [Rule] { - #Rule(Self.$moodLogCount) { $0 >= 3 } - } - - @Parameter - static var moodLogCount: Int = 0 -} - -/// Tip for HealthKit sync -struct HealthKitSyncTip: Tip { - var title: Text { - Text("Sync with Apple Health") - } - - var message: Text? { - Text("Connect to Apple Health to see your mood data alongside sleep, exercise, and more.") - } - - var image: Image? { - Image(systemName: "heart.fill") - } - - var rules: [Rule] { - #Rule(Self.$hasSeenSettings) { $0 == true } - } - - @Parameter - static var hasSeenSettings: Bool = false -} - -/// Tip for widget voting -struct WidgetVotingTip: Tip { - var title: Text { - Text("Vote from Your Home Screen") - } - - var message: Text? { - Text("Add the Mood Vote widget to quickly log your mood without opening the app.") - } - - var image: Image? { - Image(systemName: "square.grid.2x2") - } - - var rules: [Rule] { - #Rule(Self.$daysUsingApp) { $0 >= 2 } - } - - @Parameter - static var daysUsingApp: Int = 0 -} - -/// Tip for viewing different time periods -struct TimeViewTip: Tip { - var title: Text { - Text("View Your History") - } - - var message: Text? { - Text("Switch between Day, Month, and Year views to see your mood patterns over time.") - } - - var image: Image? { - Image(systemName: "calendar") +@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 } } -/// Tip for mood streaks -struct MoodStreakTip: Tip { - var title: Text { - Text("Build Your Streak!") +@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 } +} - var message: Text? { - Text("Log your mood daily to build a streak. Consistency helps you understand your patterns.") +@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 } +} - var image: Image? { - Image(systemName: "flame.fill") +@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 } +} - var rules: [Rule] { - #Rule(Self.$currentStreak) { $0 >= 3 } - } +// MARK: - All Tips - @Parameter - static var currentStreak: Int = 0 +@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 TipsManager { - static let shared = TipsManager() +class FeelsTipsManager: ObservableObject { + static let shared = FeelsTipsManager() - private init() {} - - func configure() { - try? Tips.configure([ - .displayFrequency(.daily), - .datastoreLocation(.applicationDefault) - ]) + // 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() { - try? Tips.resetDatastore() + shownTipIDs = [] + hasShownTipThisSession = false + moodLogCount = 0 + hasSeenSettings = false + daysUsingApp = 0 + currentStreak = 0 } - // Update tip parameters based on user actions + // MARK: - Event Handlers + func onMoodLogged() { - SiriShortcutTip.moodLogCount += 1 - AIInsightsTip.hasLoggedMoods += 1 + moodLogCount += 1 } func onSettingsViewed() { - HealthKitSyncTip.hasSeenSettings = true + hasSeenSettings = true } func updateDaysUsingApp(_ days: Int) { - WidgetVotingTip.daysUsingApp = days + daysUsingApp = days } func updateStreak(_ streak: Int) { - MoodStreakTip.currentStreak = streak + currentStreak = streak } } -// MARK: - Tip View Modifiers +// 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 { - self.popoverTip(CustomizeLayoutTip()) + feelsTip(FeelsTips.customizeLayout, gradientColors: [Color(hex: "667eea"), Color(hex: "764ba2")]) } func aiInsightsTip() -> some View { - self.popoverTip(AIInsightsTip()) + feelsTip(FeelsTips.aiInsights, gradientColors: [.purple, .blue]) } func siriShortcutTip() -> some View { - self.popoverTip(SiriShortcutTip()) + feelsTip(FeelsTips.siriShortcut, gradientColors: [Color(hex: "f093fb"), Color(hex: "f5576c")]) } func healthKitSyncTip() -> some View { - self.popoverTip(HealthKitSyncTip()) + feelsTip(FeelsTips.healthKitSync, gradientColors: [.red, .pink]) } func widgetVotingTip() -> some View { - self.popoverTip(WidgetVotingTip()) + feelsTip(FeelsTips.widgetVoting, gradientColors: [Color(hex: "11998e"), Color(hex: "38ef7d")]) } func timeViewTip() -> some View { - self.popoverTip(TimeViewTip()) + feelsTip(FeelsTips.timeView, gradientColors: [.blue, .cyan]) } func moodStreakTip() -> some View { - self.popoverTip(MoodStreakTip()) - } -} - -// MARK: - Inline Tip View - -struct InlineTipView: View { - let tip: any Tip - - var body: some View { - TipView(tip) - .tipBackground(Color(.secondarySystemBackground)) + feelsTip(FeelsTips.moodStreak, gradientColors: [.orange, .red]) } } diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift index 2e3444b..a3ddc07 100644 --- a/Shared/MoodLogger.swift +++ b/Shared/MoodLogger.swift @@ -82,10 +82,10 @@ final class MoodLogger { LiveActivityManager.shared.updateActivity(streak: streak, mood: mood) LiveActivityScheduler.shared.scheduleForNextDay() - // 4. Update TipKit parameters if requested + // 4. Update tips parameters if requested if updateTips { - TipsManager.shared.onMoodLogged() - TipsManager.shared.updateStreak(streak) + FeelsTipsManager.shared.onMoodLogged() + FeelsTipsManager.shared.updateStreak(streak) } // 5. Request app review at moments of delight diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift index db24e0f..113ac8c 100644 --- a/Shared/Views/CustomizeView/CustomizeView.swift +++ b/Shared/Views/CustomizeView/CustomizeView.swift @@ -7,7 +7,6 @@ import SwiftUI import StoreKit -import TipKit // MARK: - Customize Content View (for use in SettingsTabView) struct CustomizeContentView: View { @@ -20,10 +19,6 @@ struct CustomizeContentView: View { var body: some View { ScrollView { VStack(spacing: 24) { - // Customize tip - TipView(CustomizeLayoutTip()) - .tipBackground(Color(.secondarySystemBackground)) - // QUICK THEMES SettingsSection(title: "Quick Start") { Button(action: { showThemePicker = true }) { @@ -137,6 +132,7 @@ struct CustomizeContentView: View { .onAppear(perform: { EventLogger.log(event: "show_customize_view") }) + .customizeLayoutTip() } } diff --git a/Shared/Views/DayView/DayView.swift b/Shared/Views/DayView/DayView.swift index 63b806e..13457bf 100644 --- a/Shared/Views/DayView/DayView.swift +++ b/Shared/Views/DayView/DayView.swift @@ -7,7 +7,6 @@ import SwiftUI import SwiftData -import TipKit struct DayViewConstants { static let maxHeaderHeight = 200.0 diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift index 93db4cd..a76f44f 100644 --- a/Shared/Views/InsightsView/InsightsView.swift +++ b/Shared/Views/InsightsView/InsightsView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import TipKit struct InsightsView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index ad7d414..fe29874 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -8,7 +8,6 @@ import SwiftUI import UniformTypeIdentifiers import StoreKit -import TipKit // MARK: - Settings Content View (for use in SettingsTabView) struct SettingsContentView: View { @@ -55,6 +54,7 @@ struct SettingsContentView: View { trialDateButton animationLabButton paywallPreviewButton + tipsPreviewButton addTestDataButton clearDataButton #endif @@ -87,7 +87,7 @@ struct SettingsContentView: View { } .onAppear(perform: { EventLogger.log(event: "show_settings_view") - TipsManager.shared.onSettingsViewed() + FeelsTipsManager.shared.onSettingsViewed() }) } @@ -248,6 +248,7 @@ struct SettingsContentView: View { @State private var showAnimationLab = false @State private var showPaywallPreview = false + @State private var showTipsPreview = false private var animationLabButton: some View { ZStack { @@ -333,6 +334,51 @@ struct SettingsContentView: View { } } + private var tipsPreviewButton: some View { + ZStack { + theme.currentTheme.secondaryBGColor + Button { + showTipsPreview = true + } label: { + HStack(spacing: 12) { + Image(systemName: "lightbulb.fill") + .font(.title2) + .foregroundStyle( + LinearGradient( + colors: [.yellow, .orange], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Tips Preview") + .foregroundColor(textColor) + + Text("View all tip modals") + .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: $showTipsPreview) { + NavigationStack { + TipsPreviewView() + } + } + } + private var addTestDataButton: some View { ZStack { theme.currentTheme.secondaryBGColor diff --git a/Shared/Views/TipModalView.swift b/Shared/Views/TipModalView.swift new file mode 100644 index 0000000..3426caa --- /dev/null +++ b/Shared/Views/TipModalView.swift @@ -0,0 +1,380 @@ +// +// TipModalView.swift +// Feels +// +// Custom tip modal that adapts to the user's chosen theme +// + +import SwiftUI + +struct TipModalView: View { + let icon: String + let title: String + let message: String + let gradientColors: [Color] + let onDismiss: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) + private var textColor: Color = DefaultTextColor.textColor + + @State private var appeared = false + + private var primaryColor: Color { + gradientColors.first ?? .accentColor + } + + var body: some View { + VStack(spacing: 0) { + // MARK: - Gradient Header + ZStack { + // Base gradient with wave-like flow + LinearGradient( + colors: gradientColors + [gradientColors.last?.opacity(0.8) ?? .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // Subtle overlay for depth + LinearGradient( + colors: [ + .white.opacity(0.15), + .clear, + .black.opacity(0.1) + ], + startPoint: .top, + endPoint: .bottom + ) + + // Floating orb effect behind icon + Circle() + .fill( + RadialGradient( + colors: [ + .white.opacity(0.3), + .white.opacity(0.1), + .clear + ], + center: .center, + startRadius: 0, + endRadius: 60 + ) + ) + .frame(width: 120, height: 120) + .blur(radius: 8) + .offset(y: appeared ? 0 : 10) + + // Icon + Image(systemName: icon) + .font(.system(size: 44, weight: .medium)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4) + .scaleEffect(appeared ? 1 : 0.8) + .opacity(appeared ? 1 : 0) + } + .frame(height: 130) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: 24, + bottomTrailingRadius: 24, + topTrailingRadius: 0 + ) + ) + + // MARK: - Content + VStack(spacing: 12) { + Text(title) + .font(.system(.title3, design: .rounded, weight: .bold)) + .foregroundColor(textColor) + .multilineTextAlignment(.center) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + + Text(message) + .font(.system(.body, design: .rounded)) + .foregroundColor(textColor.opacity(0.7)) + .multilineTextAlignment(.center) + .lineSpacing(4) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + } + .padding(.horizontal, 24) + .padding(.top, 24) + + Spacer() + + // MARK: - Dismiss Button + Button(action: onDismiss) { + Text("Got it") + .font(.system(.headline, design: .rounded, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + ZStack { + LinearGradient( + colors: gradientColors, + startPoint: .leading, + endPoint: .trailing + ) + + // Shine effect + LinearGradient( + colors: [ + .white.opacity(0.25), + .clear + ], + startPoint: .top, + endPoint: .center + ) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow( + color: primaryColor.opacity(0.4), + radius: 12, + x: 0, + y: 6 + ) + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 20) + } + .background( + colorScheme == .dark + ? Color(.systemBackground) + : Color(.systemBackground) + ) + .onAppear { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + appeared = true + } + } + } +} + +// MARK: - View Modifier for Easy Sheet Presentation + +struct TipModalModifier: ViewModifier { + @Binding var isPresented: Bool + let icon: String + let title: String + let message: String + let gradientColors: [Color] + let onDismiss: (() -> Void)? + + func body(content: Content) -> some View { + content + .sheet(isPresented: $isPresented) { + TipModalView( + icon: icon, + title: title, + message: message, + gradientColors: gradientColors, + onDismiss: { + isPresented = false + onDismiss?() + } + ) + .presentationDetents([.height(340)]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(28) + } + } +} + +extension View { + func tipModal( + isPresented: Binding, + icon: String, + title: String, + message: String, + gradientColors: [Color], + onDismiss: (() -> Void)? = nil + ) -> some View { + modifier( + TipModalModifier( + isPresented: isPresented, + icon: icon, + title: title, + message: message, + gradientColors: gradientColors, + onDismiss: onDismiss + ) + ) + } +} + +// MARK: - Preview + +#Preview("Light Mode") { + TipModalView( + icon: "paintbrush.fill", + title: "Personalize Your Experience", + message: "Customize mood icons, colors, and layouts to make the app truly yours.", + gradientColors: [Color(hex: "667eea"), Color(hex: "764ba2")], + onDismiss: {} + ) +} + +#Preview("Dark Mode") { + TipModalView( + icon: "heart.fill", + title: "Sync with Apple Health", + message: "Connect to Apple Health to see your mood data alongside sleep, exercise, and more.", + gradientColors: [Color(hex: "f093fb"), Color(hex: "f5576c")], + onDismiss: {} + ) + .preferredColorScheme(.dark) +} + +#Preview("Zen Theme") { + TipModalView( + icon: "leaf.fill", + title: "Build Your Streak!", + message: "Log your mood daily to build a streak. Consistency helps you understand your patterns.", + gradientColors: [Color(hex: "11998e"), Color(hex: "38ef7d")], + onDismiss: {} + ) +} + +// MARK: - Tips Preview View (Debug) + +#if DEBUG +struct TipsPreviewView: View { + @Environment(\.dismiss) private var dismiss + @State private var selectedTipIndex: Int? + + private let allTips: [(tip: any FeelsTip, colors: [Color], rule: String)] = [ + (FeelsTips.customizeLayout, [Color(hex: "667eea"), Color(hex: "764ba2")], "Always eligible"), + (FeelsTips.aiInsights, [.purple, .blue], "moodLogCount >= 7"), + (FeelsTips.siriShortcut, [Color(hex: "f093fb"), Color(hex: "f5576c")], "moodLogCount >= 3"), + (FeelsTips.healthKitSync, [.red, .pink], "hasSeenSettings == true"), + (FeelsTips.widgetVoting, [Color(hex: "11998e"), Color(hex: "38ef7d")], "daysUsingApp >= 2"), + (FeelsTips.timeView, [.blue, .cyan], "Always eligible"), + (FeelsTips.moodStreak, [.orange, .red], "currentStreak >= 3") + ] + + var body: some View { + List { + Section { + ForEach(Array(allTips.enumerated()), id: \.offset) { index, tipData in + Button { + selectedTipIndex = index + } label: { + HStack(spacing: 16) { + // Gradient icon circle + ZStack { + Circle() + .fill( + LinearGradient( + colors: tipData.colors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 44, height: 44) + + Image(systemName: tipData.tip.icon) + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.white) + } + + VStack(alignment: .leading, spacing: 4) { + Text(tipData.tip.title) + .font(.headline) + .foregroundColor(.primary) + + Text(tipData.tip.id) + .font(.caption) + .foregroundColor(.secondary) + + Text(tipData.rule) + .font(.caption2) + .foregroundColor(.orange) + } + + Spacer() + + // Eligibility indicator + Circle() + .fill(tipData.tip.isEligible ? Color.green : Color.gray.opacity(0.3)) + .frame(width: 10, height: 10) + } + .padding(.vertical, 4) + } + } + } header: { + Text("Tap to preview") + } footer: { + Text("Green dot = eligible to show. Tips only show once per session when eligible.") + } + + Section { + Button("Reset All Tips") { + FeelsTipsManager.shared.resetAllTips() + } + .foregroundColor(.red) + + Toggle("Tips Enabled", isOn: Binding( + get: { FeelsTipsManager.shared.tipsEnabled }, + set: { FeelsTipsManager.shared.tipsEnabled = $0 } + )) + } header: { + Text("Settings") + } + + Section { + LabeledContent("Mood Log Count", value: "\(FeelsTipsManager.shared.moodLogCount)") + LabeledContent("Days Using App", value: "\(FeelsTipsManager.shared.daysUsingApp)") + LabeledContent("Current Streak", value: "\(FeelsTipsManager.shared.currentStreak)") + LabeledContent("Has Seen Settings", value: FeelsTipsManager.shared.hasSeenSettings ? "Yes" : "No") + LabeledContent("Shown This Session", value: FeelsTipsManager.shared.hasShownTipThisSession ? "Yes" : "No") + } header: { + Text("Current Parameters") + } + } + .navigationTitle("Tips Preview") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + .sheet(item: Binding( + get: { selectedTipIndex.map { TipIndexWrapper(index: $0) } }, + set: { selectedTipIndex = $0?.index } + )) { wrapper in + let tipData = allTips[wrapper.index] + TipModalView( + icon: tipData.tip.icon, + title: tipData.tip.title, + message: tipData.tip.message, + gradientColors: tipData.colors, + onDismiss: { + selectedTipIndex = nil + } + ) + .presentationDetents([.height(340)]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(28) + } + } +} + +private struct TipIndexWrapper: Identifiable { + let index: Int + var id: Int { index } +} + +#Preview("Tips Preview") { + NavigationStack { + TipsPreviewView() + } +} +#endif diff --git a/docs/Apple-Features.md b/docs/Apple-Features.md index c533c8c..79b64b4 100644 --- a/docs/Apple-Features.md +++ b/docs/Apple-Features.md @@ -109,38 +109,42 @@ This document covers the new Apple-specific features integrated into Feels, incl --- -## 4. TipKit +## 4. Custom Tips System -**File:** `Shared/FeelsTips.swift` +**Files:** +- `Shared/FeelsTips.swift` (Tips definitions and manager) +- `Shared/Views/TipModalView.swift` (Modal UI) -**What it does:** Shows contextual tips to help users discover features throughout the app. +**What it does:** Shows themed modal tips to help users discover features throughout the app. Tips appear as beautiful sheets that match the app's current theme. ### Available Tips | Tip | Location | Trigger | |-----|----------|---------| | CustomizeLayoutTip | Customize screen | First visit | -| AIInsightsTip | Insights tab | After 7 days of data | +| AIInsightsTip | Insights tab | After 7 moods logged | | SiriShortcutTip | Settings | After 3 mood logs | -| HealthKitSyncTip | Settings | After 5 mood logs | -| WidgetVotingTip | Day view | After first mood log | -| TimeViewTip | Day view header | After 2 days usage | +| HealthKitSyncTip | Settings | After viewing settings | +| WidgetVotingTip | Day view | After 2 days usage | +| TimeViewTip | Day view | First visit | | MoodStreakTip | Day view | When streak >= 3 | ### How to Test -1. Tips appear automatically based on conditions -2. To reset tips for testing, add this code temporarily: +1. Tips appear automatically based on conditions (one per session) +2. To reset tips for testing: ```swift -try? Tips.resetDatastore() +FeelsTipsManager.shared.resetAllTips() +``` +3. To disable tips globally: +```swift +FeelsTipsManager.shared.tipsEnabled = false ``` -3. Or use the Tips debug menu in Xcode: - - Edit Scheme > Run > Arguments - - Add: `-com.apple.TipKit.DisplayFrequency weekly` ### Implementation Details -- `TipsManager.shared.configure()` called in `FeelsApp.init()` -- Each tip has rules based on `@Parameter` events and conditions -- Tips automatically dismiss after user interaction +- `FeelsTipsManager.shared.resetSession()` called in `FeelsApp.init()` +- Each tip has `isEligible` property based on user activity parameters +- Tips show as themed modal sheets with gradient headers +- Only one tip shown per app session --- diff --git a/docs/TipKit-Tips.md b/docs/TipKit-Tips.md index 24d66fd..20578cd 100644 --- a/docs/TipKit-Tips.md +++ b/docs/TipKit-Tips.md @@ -1,12 +1,13 @@ -# TipKit Tips Documentation +# Custom Tips System Documentation -This document describes all TipKit tips implemented in the Feels app, including their display conditions and locations. +This document describes all tips implemented in the Feels app, including their display conditions and locations. ## Overview -Tips are managed by `TipsManager` (singleton) and configured with: -- **Display Frequency**: Daily -- **Datastore Location**: Application default +Tips are displayed as themed modal sheets that match the user's chosen app theme. The system is managed by `FeelsTipsManager` (singleton) and configured with: +- **Display Frequency**: One tip per app session +- **Global Toggle**: `tipsEnabled` boolean in UserDefaults +- **Persistence**: Shown tip IDs stored in UserDefaults --- @@ -15,12 +16,12 @@ Tips are managed by `TipsManager` (singleton) and configured with: ### 1. CustomizeLayoutTip **Title**: "Personalize Your Experience" -**Message**: "Tap here to customize mood icons, colors, and layouts." -**Icon**: `paintbrush` +**Message**: "Customize mood icons, colors, and layouts to make the app truly yours." +**Icon**: `paintbrush.fill` **Display Conditions**: Always eligible (no rules) -**Location**: CustomizeContentView (top of the Customize tab in Settings) +**Location**: CustomizeContentView (via `.customizeLayoutTip()`) --- @@ -28,14 +29,14 @@ Tips are managed by `TipsManager` (singleton) and configured with: **Title**: "Discover AI Insights" **Message**: "Get personalized insights about your mood patterns powered by Apple Intelligence." -**Icon**: `brain` +**Icon**: `brain.head.profile` **Display Conditions**: - User has logged at least **7 moods** -**Parameter**: `hasLoggedMoods: Int` (incremented via `TipsManager.shared.onMoodLogged()`) +**Parameter**: `moodLogCount: Int` (incremented via `FeelsTipsManager.shared.onMoodLogged()`) -**Location**: InsightsView +**Location**: InsightsView (via `.aiInsightsTip()`) --- @@ -48,7 +49,7 @@ Tips are managed by `TipsManager` (singleton) and configured with: **Display Conditions**: - User has logged at least **3 moods** -**Parameter**: `moodLogCount: Int` (incremented via `TipsManager.shared.onMoodLogged()`) +**Parameter**: `moodLogCount: Int` (incremented via `FeelsTipsManager.shared.onMoodLogged()`) **Location**: SettingsContentView (Features section header, via `.siriShortcutTip()`) @@ -63,7 +64,7 @@ Tips are managed by `TipsManager` (singleton) and configured with: **Display Conditions**: - User has viewed the Settings screen -**Parameter**: `hasSeenSettings: Bool` (set via `TipsManager.shared.onSettingsViewed()`) +**Parameter**: `hasSeenSettings: Bool` (set via `FeelsTipsManager.shared.onSettingsViewed()`) **Location**: SettingsContentView (Health Kit toggle, via `.healthKitSyncTip()`) @@ -73,14 +74,14 @@ Tips are managed by `TipsManager` (singleton) and configured with: **Title**: "Vote from Your Home Screen" **Message**: "Add the Mood Vote widget to quickly log your mood without opening the app." -**Icon**: `square.grid.2x2` +**Icon**: `square.grid.2x2.fill` **Display Conditions**: - User has been using the app for at least **2 days** -**Parameter**: `daysUsingApp: Int` (updated via `TipsManager.shared.updateDaysUsingApp(_:)`) +**Parameter**: `daysUsingApp: Int` -**Location**: DayView +**Location**: DayView (via `.widgetVotingTip()`) --- @@ -92,7 +93,7 @@ Tips are managed by `TipsManager` (singleton) and configured with: **Display Conditions**: Always eligible (no rules) -**Location**: DayView +**Location**: DayView (via `.timeViewTip()`) --- @@ -105,26 +106,29 @@ Tips are managed by `TipsManager` (singleton) and configured with: **Display Conditions**: - User has a current streak of at least **3 days** -**Parameter**: `currentStreak: Int` (updated via `TipsManager.shared.updateStreak(_:)`) +**Parameter**: `currentStreak: Int` (updated via `FeelsTipsManager.shared.updateStreak(_:)`) -**Location**: DayView +**Location**: DayView (via `.moodStreakTip()`) --- -## TipsManager API +## FeelsTipsManager API ```swift -// Configure tips (call on app launch) -TipsManager.shared.configure() +// Reset session flag (call on app launch) +FeelsTipsManager.shared.resetSession() // Reset all tips (for testing) -TipsManager.shared.resetAllTips() +FeelsTipsManager.shared.resetAllTips() // Update parameters -TipsManager.shared.onMoodLogged() // Increments mood log count -TipsManager.shared.onSettingsViewed() // Marks settings as viewed -TipsManager.shared.updateDaysUsingApp(_:) // Updates days using app -TipsManager.shared.updateStreak(_:) // Updates current streak +FeelsTipsManager.shared.onMoodLogged() // Increments mood log count +FeelsTipsManager.shared.onSettingsViewed() // Marks settings as viewed +FeelsTipsManager.shared.updateDaysUsingApp(_:) // Updates days using app +FeelsTipsManager.shared.updateStreak(_:) // Updates current streak + +// Global toggle +FeelsTipsManager.shared.tipsEnabled = true/false ``` --- @@ -141,12 +145,26 @@ Tips can be attached to views using these convenience modifiers: .widgetVotingTip() .timeViewTip() .moodStreakTip() + +// Or use the generic modifier with custom gradient colors: +.feelsTip(FeelsTips.customizeLayout, gradientColors: [.purple, .blue]) ``` --- +## Modal Design + +Tips are displayed as themed modal sheets with: +- Gradient header (130pt) matching tip-specific colors +- SF Symbol icon (44pt, white) +- Title and message with theme text color +- "Got it" dismiss button with gradient background +- Spring animation on appearance + +--- + ## Files -- **Definition**: `Shared/FeelsTips.swift` -- **Manager**: `TipsManager` class in same file -- **Configuration**: Called in `FeelsApp.swift` +- **Tips & Manager**: `Shared/FeelsTips.swift` +- **Modal View**: `Shared/Views/TipModalView.swift` +- **Configuration**: `FeelsTipsManager.shared.resetSession()` called in `FeelsApp.swift`