Replace TipKit with custom themed tips modal system
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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" : {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<String> {
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import TipKit
|
||||
|
||||
struct DayViewConstants {
|
||||
static let maxHeaderHeight = 200.0
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
struct InsightsView: View {
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
|
||||
@@ -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
|
||||
|
||||
380
Shared/Views/TipModalView.swift
Normal file
380
Shared/Views/TipModalView.swift
Normal file
@@ -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<Bool>,
|
||||
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
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user