// // 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 ) } .accessibilityIdentifier(AccessibilityID.TipModal.dismissButton) .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, 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) 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) } .accessibilityIdentifier(AccessibilityID.TipModal.tipPreviewButton(index)) } } 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) .accessibilityIdentifier(AccessibilityID.TipModal.resetTipsButton) Toggle("Tips Enabled", isOn: Binding( get: { ReflectTipsManager.shared.tipsEnabled }, set: { ReflectTipsManager.shared.tipsEnabled = $0 } )) .accessibilityIdentifier(AccessibilityID.TipModal.tipsEnabledToggle) } 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() } .accessibilityIdentifier(AccessibilityID.TipModal.doneButton) } } .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() } }