Files
Reflect/Shared/FeelsTips.swift
Trey t 810ac2d649 Update signing configuration to use 88oakapps.feels identifiers
- Update App Group IDs from group.com.tt.feels to group.com.88oakapps.feels
- Update iCloud container IDs from iCloud.com.tt.feels to iCloud.com.88oakapps.feels
- Sync code constants with entitlements across all targets (iOS, Watch, Widget)
- Update documentation in CLAUDE.md and PROJECT_OVERVIEW.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:01:49 -06:00

322 lines
9.5 KiB
Swift

//
// FeelsTips.swift
// Feels
//
// Custom tips system for feature discovery and onboarding
//
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
@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 }
}
@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
}
}
@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
}
}
@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
}
}
@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
}
}
@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
}
}
// MARK: - All Tips
@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 FeelsTipsManager: ObservableObject {
static let shared = FeelsTipsManager()
// 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() {
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 FeelsTipModifier: ViewModifier {
let tip: any FeelsTip
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 FeelsTipsManager.shared.shouldShowTip(tip) {
showSheet = true
}
}
}
.sheet(isPresented: $showSheet) {
TipModalView(
icon: tip.icon,
title: tip.title,
message: tip.message,
gradientColors: gradientColors,
onDismiss: {
showSheet = false
FeelsTipsManager.shared.markTipAsShown(tip)
}
)
.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 {
feelsTip(FeelsTips.customizeLayout, gradientColors: [Color(hex: "667eea"), Color(hex: "764ba2")])
}
func aiInsightsTip() -> some View {
feelsTip(FeelsTips.aiInsights, gradientColors: [.purple, .blue])
}
func siriShortcutTip() -> some View {
feelsTip(FeelsTips.siriShortcut, gradientColors: [Color(hex: "f093fb"), Color(hex: "f5576c")])
}
func healthKitSyncTip() -> some View {
feelsTip(FeelsTips.healthKitSync, gradientColors: [.red, .pink])
}
func widgetVotingTip() -> some View {
feelsTip(FeelsTips.widgetVoting, gradientColors: [Color(hex: "11998e"), Color(hex: "38ef7d")])
}
func timeViewTip() -> some View {
feelsTip(FeelsTips.timeView, gradientColors: [.blue, .cyan])
}
func moodStreakTip() -> some View {
feelsTip(FeelsTips.moodStreak, gradientColors: [.orange, .red])
}
}