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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import TipKit
|
||||
|
||||
struct DayViewConstants {
|
||||
static let maxHeaderHeight = 200.0
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
struct InsightsView: View {
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
|
||||
@@ -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
|
||||
|
||||
380
Shared/Views/TipModalView.swift
Normal file
380
Shared/Views/TipModalView.swift
Normal 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
|
||||
Reference in New Issue
Block a user