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:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user