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:
Trey t
2025-12-28 21:33:36 -06:00
parent e98142c72e
commit c59f215535
11 changed files with 809 additions and 222 deletions

View File

@@ -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" : {

View File

@@ -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()

View File

@@ -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])
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -7,7 +7,6 @@
import SwiftUI
import SwiftData
import TipKit
struct DayViewConstants {
static let maxHeaderHeight = 200.0

View File

@@ -6,7 +6,6 @@
//
import SwiftUI
import TipKit
struct InsightsView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system

View File

@@ -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

View 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

View File

@@ -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
---

View File

@@ -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`