Files
Reflect/Shared/ReflectTips.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

322 lines
9.6 KiB
Swift

//
// ReflectTips.swift
// Reflect
//
// Custom tips system for feature discovery and onboarding
//
import SwiftUI
// MARK: - ReflectTip Protocol
@MainActor
protocol ReflectTip: Identifiable {
var id: String { get }
var title: String { get }
var message: String { get }
var icon: String { get }
var isEligible: Bool { get }
}
// MARK: - Tip Definitions
@MainActor
struct CustomizeLayoutTip: ReflectTip {
let id = "customizeLayout"
let title = "Personalize Your Experience"
let message = "Customize mood icons, colors, and layouts to make the app truly yours."
let icon = "paintbrush.fill"
var isEligible: Bool { true }
}
@MainActor
struct AIInsightsTip: ReflectTip {
let id = "aiInsights"
let title = "Discover AI Insights"
let message = "Get personalized insights about your mood patterns powered by Apple Intelligence."
let icon = "brain.head.profile"
var isEligible: Bool {
ReflectTipsManager.shared.moodLogCount >= 7
}
}
@MainActor
struct SiriShortcutTip: ReflectTip {
let id = "siriShortcut"
let title = "Use Siri to Log Moods"
let message = "Say \"Hey Siri, log my mood as great in Reflect\" for hands-free logging."
let icon = "mic.fill"
var isEligible: Bool {
ReflectTipsManager.shared.moodLogCount >= 3
}
}
@MainActor
struct HealthKitSyncTip: ReflectTip {
let id = "healthKitSync"
let title = "Sync with Apple Health"
let message = "Connect to Apple Health to see your mood data alongside sleep, exercise, and more."
let icon = "heart.fill"
var isEligible: Bool {
ReflectTipsManager.shared.hasSeenSettings
}
}
@MainActor
struct WidgetVotingTip: ReflectTip {
let id = "widgetVoting"
let title = "Vote from Your Home Screen"
let message = "Add the Mood Vote widget to quickly log your mood without opening the app."
let icon = "square.grid.2x2.fill"
var isEligible: Bool {
ReflectTipsManager.shared.daysUsingApp >= 2
}
}
@MainActor
struct TimeViewTip: ReflectTip {
let id = "timeView"
let title = "View Your History"
let message = "Switch between Day, Month, and Year views to see your mood patterns over time."
let icon = "calendar"
var isEligible: Bool { true }
}
@MainActor
struct MoodStreakTip: ReflectTip {
let id = "moodStreak"
let title = "Build Your Streak!"
let message = "Log your mood daily to build a streak. Consistency helps you understand your patterns."
let icon = "flame.fill"
var isEligible: Bool {
ReflectTipsManager.shared.currentStreak >= 3
}
}
// MARK: - All Tips
@MainActor
enum ReflectTips {
static let customizeLayout = CustomizeLayoutTip()
static let aiInsights = AIInsightsTip()
static let siriShortcut = SiriShortcutTip()
static let healthKitSync = HealthKitSyncTip()
static let widgetVoting = WidgetVotingTip()
static let timeView = TimeViewTip()
static let moodStreak = MoodStreakTip()
}
// MARK: - Tips Manager
@MainActor
class ReflectTipsManager: ObservableObject {
static let shared = ReflectTipsManager()
// MARK: - Keys
private enum Keys {
static let tipsEnabled = "reflect.tips.enabled"
static let shownTipIDs = "reflect.tips.shownIDs"
static let moodLogCount = "reflect.tips.moodLogCount"
static let hasSeenSettings = "reflect.tips.hasSeenSettings"
static let daysUsingApp = "reflect.tips.daysUsingApp"
static let currentStreak = "reflect.tips.currentStreak"
}
// MARK: - Published State
@Published var currentTip: (any ReflectTip)?
@Published var showTipModal = false
// MARK: - Global Toggle (configurable)
var tipsEnabled: Bool {
get { UserDefaults.standard.bool(forKey: Keys.tipsEnabled) }
set { UserDefaults.standard.set(newValue, forKey: Keys.tipsEnabled) }
}
// MARK: - Session Tracking
private(set) var hasShownTipThisSession = false
// MARK: - Shown Tips (persisted)
private var shownTipIDs: Set<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 ReflectTip) -> Bool {
guard tipsEnabled else { return false }
guard !hasShownTipThisSession else { return false }
guard !shownTipIDs.contains(tip.id) else { return false }
guard tip.isEligible else { return false }
return true
}
/// Show a tip if eligible
func showTipIfEligible(_ tip: any ReflectTip) {
guard shouldShowTip(tip) else { return }
currentTip = tip
showTipModal = true
}
/// Mark a tip as shown (called when dismissed)
func markTipAsShown(_ tip: any ReflectTip) {
shownTipIDs.insert(tip.id)
hasShownTipThisSession = true
currentTip = nil
showTipModal = false
}
/// Reset session flag (call on app launch)
func resetSession() {
hasShownTipThisSession = false
}
/// Reset all tips (for testing)
func resetAllTips() {
shownTipIDs = []
hasShownTipThisSession = false
moodLogCount = 0
hasSeenSettings = false
daysUsingApp = 0
currentStreak = 0
}
// MARK: - Event Handlers
func onMoodLogged() {
moodLogCount += 1
}
func onSettingsViewed() {
hasSeenSettings = true
}
func updateDaysUsingApp(_ days: Int) {
daysUsingApp = days
}
func updateStreak(_ streak: Int) {
currentStreak = streak
}
}
// MARK: - View Modifier for Easy Integration
struct ReflectTipModifier: ViewModifier {
let tip: any ReflectTip
let gradientColors: [Color]
// Use local state for sheet to avoid interference from other manager state changes
@State private var showSheet = false
@State private var hasCheckedEligibility = false
func body(content: Content) -> some View {
content
.onAppear {
// Only check eligibility once per view lifetime
guard !hasCheckedEligibility else { return }
hasCheckedEligibility = true
// Delay tip presentation to ensure view hierarchy is fully established
// This prevents "presenting from detached view controller" errors
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if ReflectTipsManager.shared.shouldShowTip(tip) {
showSheet = true
}
}
}
.sheet(isPresented: $showSheet) {
TipModalView(
icon: tip.icon,
title: tip.title,
message: tip.message,
gradientColors: gradientColors,
onDismiss: {
showSheet = false
ReflectTipsManager.shared.markTipAsShown(tip)
}
)
.presentationDetents([.height(340)])
.presentationDragIndicator(.visible)
.presentationCornerRadius(28)
}
}
}
extension View {
/// Attach a tip that shows as a themed modal when eligible
func reflectTip(_ tip: any ReflectTip, gradientColors: [Color]) -> some View {
modifier(ReflectTipModifier(tip: tip, gradientColors: gradientColors))
}
// MARK: - Convenience Modifiers
/// Default gradient for tips
private var defaultTipGradient: [Color] {
[Color(hex: "667eea"), Color(hex: "764ba2")]
}
func customizeLayoutTip() -> some View {
reflectTip(ReflectTips.customizeLayout, gradientColors: [Color(hex: "667eea"), Color(hex: "764ba2")])
}
func aiInsightsTip() -> some View {
reflectTip(ReflectTips.aiInsights, gradientColors: [.purple, .blue])
}
func siriShortcutTip() -> some View {
reflectTip(ReflectTips.siriShortcut, gradientColors: [Color(hex: "f093fb"), Color(hex: "f5576c")])
}
func healthKitSyncTip() -> some View {
reflectTip(ReflectTips.healthKitSync, gradientColors: [.red, .pink])
}
func widgetVotingTip() -> some View {
reflectTip(ReflectTips.widgetVoting, gradientColors: [Color(hex: "11998e"), Color(hex: "38ef7d")])
}
func timeViewTip() -> some View {
reflectTip(ReflectTips.timeView, gradientColors: [.blue, .cyan])
}
func moodStreakTip() -> some View {
reflectTip(ReflectTips.moodStreak, gradientColors: [.orange, .red])
}
}