- Wrap 30+ production print() statements in #if DEBUG guards across 18 files - Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets - Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views - Add text alternatives for color-only indicators (progress dots, mood circles) - Localize raw string literals in NoteEditorView, EntryDetailView, widgets - Replace 25+ silent try? with do/catch + AppLogger error logging - Replace hardcoded font sizes with semantic Dynamic Type fonts - Fix FIXME in IconPickerView (log icon change errors) - Extract magic animation delays to named constants across 8 files - Add widget empty state "Log your first mood!" messaging - Hide decorative images from VoiceOver, add labels to ColorPickers - Remove stale TODO in Color+Codable (alpha change deferred for migration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
326 lines
9.7 KiB
Swift
326 lines
9.7 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 {
|
|
private enum AnimationConstants {
|
|
static let tipPresentationDelay: TimeInterval = 0.5
|
|
}
|
|
|
|
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() + AnimationConstants.tipPresentationDelay) {
|
|
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])
|
|
}
|
|
}
|