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

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