Files
Reflect/Shared/Views/TipModalView.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

383 lines
13 KiB
Swift

//
// TipModalView.swift
// Reflect
//
// 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.theme.rawValue, store: GroupUserDefaults.groupDefaults)
private var theme: Theme = .system
private var textColor: Color { theme.currentTheme.labelColor }
@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 ReflectTip, colors: [Color], rule: String)] = [
(ReflectTips.customizeLayout, [Color(hex: "667eea"), Color(hex: "764ba2")], "Always eligible"),
(ReflectTips.aiInsights, [.purple, .blue], "moodLogCount >= 7"),
(ReflectTips.siriShortcut, [Color(hex: "f093fb"), Color(hex: "f5576c")], "moodLogCount >= 3"),
(ReflectTips.healthKitSync, [.red, .pink], "hasSeenSettings == true"),
(ReflectTips.widgetVoting, [Color(hex: "11998e"), Color(hex: "38ef7d")], "daysUsingApp >= 2"),
(ReflectTips.timeView, [.blue, .cyan], "Always eligible"),
(ReflectTips.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") {
ReflectTipsManager.shared.resetAllTips()
}
.foregroundColor(.red)
Toggle("Tips Enabled", isOn: Binding(
get: { ReflectTipsManager.shared.tipsEnabled },
set: { ReflectTipsManager.shared.tipsEnabled = $0 }
))
} header: {
Text("Settings")
}
Section {
LabeledContent("Mood Log Count", value: "\(ReflectTipsManager.shared.moodLogCount)")
LabeledContent("Days Using App", value: "\(ReflectTipsManager.shared.daysUsingApp)")
LabeledContent("Current Streak", value: "\(ReflectTipsManager.shared.currentStreak)")
LabeledContent("Has Seen Settings", value: ReflectTipsManager.shared.hasSeenSettings ? "Yes" : "No")
LabeledContent("Shown This Session", value: ReflectTipsManager.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