- 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>
386 lines
13 KiB
Swift
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()
|
|
}
|
|
}
|