Files
Reflect/Shared/Views/TipModalView.swift
Trey t 329fb7c671 Remove #if DEBUG guards for TestFlight, polish weekly digest and insights UX
- Remove #if DEBUG from all debug settings, exporters, and IAP bypass so
  debug options are available in TestFlight builds
- Weekly digest card: replace dismiss X with collapsible chevron caret
- Weekly digest: generate on-demand when opening Insights tab if no cached
  digest exists (BGTask + notification kept as bonus path)
- Fix digest intention text color (was .secondary, now uses theme textColor)
- Add "Generate Weekly Digest" debug button in Settings
- Add generating overlay on Insights tab with pulsing sparkles icon that
  stays visible until all sections finish loading (content at 0.2 opacity)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:15:23 -05:00

386 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
)
}
.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<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)
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()
}
}