From 329fb7c6719c85c4557b9560c7bb6bbdef5d1c05 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 4 Apr 2026 11:15:23 -0500 Subject: [PATCH] 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) --- Reflect/Localizable.xcstrings | 32 ++++ Shared/IAPManager.swift | 8 - Shared/LocalNotification.swift | 2 - Shared/Persisence/DataControllerHelper.swift | 4 - Shared/Services/ExportableInsightsViews.swift | 3 - Shared/Services/ExportableWatchViews.swift | 3 - Shared/Services/ExportableWidgetViews.swift | 3 - Shared/Services/InsightsExporter.swift | 2 - .../Services/SharingScreenshotExporter.swift | 2 - Shared/Services/WatchExporter.swift | 2 - Shared/Services/WidgetExporter.swift | 2 - Shared/Views/InsightsView/InsightsView.swift | 76 ++++++++-- .../InsightsView/WeeklyDigestCardView.swift | 142 +++++++++--------- .../PaywallPreviewSettingsView.swift | 2 - Shared/Views/SettingsView/SettingsView.swift | 64 +++++++- Shared/Views/TipModalView.swift | 2 - 16 files changed, 232 insertions(+), 117 deletions(-) diff --git a/Reflect/Localizable.xcstrings b/Reflect/Localizable.xcstrings index ef20aae..73a30b3 100644 --- a/Reflect/Localizable.xcstrings +++ b/Reflect/Localizable.xcstrings @@ -3275,6 +3275,10 @@ } } }, + "Apple Intelligence is analyzing your mood data..." : { + "comment" : "A description of the process of generating insights.", + "isCommentAutoGenerated" : true + }, "Apple Intelligence Required" : { "comment" : "A title for a card that informs the user that AI report generation requires Apple Intelligence to be enabled in Settings.", "extractionState" : "stale", @@ -4672,6 +4676,10 @@ } } }, + "Create AI digest now (shows in Insights tab)" : { + "comment" : "A description of the action of generating a digest.", + "isCommentAutoGenerated" : true + }, "Create random icons" : { "localizations" : { "de" : { @@ -9035,6 +9043,10 @@ } } }, + "Generate Weekly Digest" : { + "comment" : "A button that generates a weekly digest of your app's usage.", + "isCommentAutoGenerated" : true + }, "Generating AI summary..." : { "comment" : "Text displayed in the progress indicator while generating the AI-generated quick summary report.", "isCommentAutoGenerated" : true, @@ -9077,6 +9089,10 @@ } } }, + "Generating Insights" : { + "comment" : "A message displayed when the app is generating user insights.", + "isCommentAutoGenerated" : true + }, "Generating monthly summaries..." : { "comment" : "Message displayed in the progress view while generating monthly summaries for the detailed report.", "isCommentAutoGenerated" : true, @@ -9465,6 +9481,10 @@ } } }, + "Great job completing your reflection. Taking time to check in with yourself is a powerful habit." : { + "comment" : "A fallback message displayed when the AI is unavailable.", + "isCommentAutoGenerated" : true + }, "Green dot = eligible to show. Tips only show once per session when eligible." : { "comment" : "A footer label explaining that tips are only shown once per session and that the green dot indicates whether a tip is currently eligible to be shown.", "isCommentAutoGenerated" : true, @@ -26903,6 +26923,10 @@ } } }, + "Weekly Digest" : { + "comment" : "A label displayed above the weekly digest.", + "isCommentAutoGenerated" : true + }, "WeekTotalTemplate body" : { "comment" : "The body text for the WeekTotalTemplate view.", "isCommentAutoGenerated" : true, @@ -27864,6 +27888,14 @@ } } } + }, + "Your Reflection" : { + "comment" : "A title describing the reflection.", + "isCommentAutoGenerated" : true + }, + "Your Weekly Digest" : { + "comment" : "Title of a notification that appears weekly.", + "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift index 073a4df..0d449c6 100644 --- a/Shared/IAPManager.swift +++ b/Shared/IAPManager.swift @@ -37,13 +37,9 @@ class IAPManager: ObservableObject { /// Set to `true` to bypass all subscription checks and grant full access (for development only) /// Togglable at runtime in DEBUG builds via Settings > Debug > Bypass Subscription - #if DEBUG @Published var bypassSubscription: Bool { didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") } } - #else - let bypassSubscription = false - #endif // MARK: - Constants @@ -140,9 +136,7 @@ class IAPManager: ObservableObject { // MARK: - Initialization init() { - #if DEBUG self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription") - #endif restoreCachedSubscriptionState() updateListenerTask = listenForTransactions() @@ -365,7 +359,6 @@ class IAPManager: ObservableObject { return false } - #if DEBUG /// Reset subscription state for UI testing. Called after group defaults are cleared /// so that stale cached state from previous test runs is discarded. func resetForTesting() { @@ -382,7 +375,6 @@ class IAPManager: ObservableObject { updateTrialState() } - #endif private func updateTrialState() { let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 diff --git a/Shared/LocalNotification.swift b/Shared/LocalNotification.swift index 447dd3a..c23aaf3 100644 --- a/Shared/LocalNotification.swift +++ b/Shared/LocalNotification.swift @@ -157,7 +157,6 @@ class LocalNotification { } } - #if DEBUG /// Sends one notification from each personality pack, staggered over 10 seconds for screenshot public class func sendAllPersonalityNotificationsForScreenshot() { let _ = createNotificationCategory() @@ -195,5 +194,4 @@ class LocalNotification { } } } - #endif } diff --git a/Shared/Persisence/DataControllerHelper.swift b/Shared/Persisence/DataControllerHelper.swift index 28fae48..a0f30b8 100644 --- a/Shared/Persisence/DataControllerHelper.swift +++ b/Shared/Persisence/DataControllerHelper.swift @@ -26,7 +26,6 @@ extension DataController { } func populateMemory() { - #if DEBUG for idx in 1..<255 { let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date())! var moodValue = Int.random(in: 2...4) @@ -43,7 +42,6 @@ extension DataController { modelContext.insert(entry) } save() - #endif } /// Creates an entry that is NOT inserted into the context - used for UI placeholders @@ -79,7 +77,6 @@ extension DataController { saveAndRunDataListeners() } - #if DEBUG func populate2YearsData() { clearDB() @@ -100,7 +97,6 @@ extension DataController { saveAndRunDataListeners() } - #endif private static func randomMood() -> Mood { var moodValue = Int.random(in: 3...4) diff --git a/Shared/Services/ExportableInsightsViews.swift b/Shared/Services/ExportableInsightsViews.swift index d83c1a5..e3feae9 100644 --- a/Shared/Services/ExportableInsightsViews.swift +++ b/Shared/Services/ExportableInsightsViews.swift @@ -4,8 +4,6 @@ // // Exportable insights views with sample AI-generated insights for screenshots. // - -#if DEBUG import SwiftUI // MARK: - Sample Insights Data @@ -377,4 +375,3 @@ struct ExportableInsightsContainer: View { .background(backgroundColor) } } -#endif diff --git a/Shared/Services/ExportableWatchViews.swift b/Shared/Services/ExportableWatchViews.swift index f8478fa..939324d 100644 --- a/Shared/Services/ExportableWatchViews.swift +++ b/Shared/Services/ExportableWatchViews.swift @@ -5,8 +5,6 @@ // Exportable watch views that match the real watchOS layouts. // These views accept tint/icon configuration as parameters for batch export. // - -#if DEBUG import SwiftUI // MARK: - Watch Export Configuration @@ -362,4 +360,3 @@ struct ExportableComplicationContainer: View { .clipShape(isCircular ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: 12, style: .continuous))) } } -#endif diff --git a/Shared/Services/ExportableWidgetViews.swift b/Shared/Services/ExportableWidgetViews.swift index ff6a5a9..7285218 100644 --- a/Shared/Services/ExportableWidgetViews.swift +++ b/Shared/Services/ExportableWidgetViews.swift @@ -5,8 +5,6 @@ // Exportable widget views that match the real WidgetKit widgets pixel-for-pixel. // These views accept tint/icon configuration as parameters for batch export. // - -#if DEBUG import SwiftUI // MARK: - Widget Theme Configuration @@ -691,4 +689,3 @@ struct ExportableWidgetContainer: View { .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) } } -#endif diff --git a/Shared/Services/InsightsExporter.swift b/Shared/Services/InsightsExporter.swift index 3fc5f9c..b132161 100644 --- a/Shared/Services/InsightsExporter.swift +++ b/Shared/Services/InsightsExporter.swift @@ -5,7 +5,6 @@ // Debug utility to export insights view screenshots with sample AI data. // -#if DEBUG import SwiftUI import UIKit @@ -100,4 +99,3 @@ class InsightsExporter { } } } -#endif diff --git a/Shared/Services/SharingScreenshotExporter.swift b/Shared/Services/SharingScreenshotExporter.swift index ad9675a..c974ddc 100644 --- a/Shared/Services/SharingScreenshotExporter.swift +++ b/Shared/Services/SharingScreenshotExporter.swift @@ -5,7 +5,6 @@ // Debug utility to export sharing template screenshots. // -#if DEBUG import SwiftUI import UIKit @@ -173,4 +172,3 @@ class SharingScreenshotExporter { return false } } -#endif diff --git a/Shared/Services/WatchExporter.swift b/Shared/Services/WatchExporter.swift index b1d0aed..f238365 100644 --- a/Shared/Services/WatchExporter.swift +++ b/Shared/Services/WatchExporter.swift @@ -6,7 +6,6 @@ // Uses the exportable watch views from ExportableWatchViews.swift. // -#if DEBUG import SwiftUI import UIKit @@ -247,4 +246,3 @@ class WatchExporter { } } } -#endif diff --git a/Shared/Services/WidgetExporter.swift b/Shared/Services/WidgetExporter.swift index 05ce11c..71d813b 100644 --- a/Shared/Services/WidgetExporter.swift +++ b/Shared/Services/WidgetExporter.swift @@ -6,7 +6,6 @@ // Uses the real widget view layouts from ExportableWidgetViews.swift. // -#if DEBUG import SwiftUI import UIKit @@ -389,4 +388,3 @@ class WidgetExporter { } } } -#endif diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift index 5d996dd..d1de6ce 100644 --- a/Shared/Views/InsightsView/InsightsView.swift +++ b/Shared/Views/InsightsView/InsightsView.swift @@ -84,6 +84,10 @@ struct InsightsView: View { if iapManager.shouldShowPaywall { paywallOverlay } + + if selectedTab == .insights && isGeneratingInsights && !iapManager.shouldShowPaywall { + generatingOverlay + } } } .sheet(isPresented: $showSubscriptionStore) { @@ -104,12 +108,22 @@ struct InsightsView: View { // MARK: - Insights Content private func loadWeeklyDigest() { - if #available(iOS 26, *), !iapManager.shouldShowPaywall { - if let digest = FoundationModelsDigestService.shared.loadLatestDigest(), - digest.isFromCurrentWeek, - !WeeklyDigest.isDismissed(for: digest) { + guard #available(iOS 26, *), !iapManager.shouldShowPaywall else { return } + + // Try cached digest first + if let digest = FoundationModelsDigestService.shared.loadLatestDigest(), + digest.isFromCurrentWeek { + weeklyDigest = digest + return + } + + // No digest for this week — generate one on-demand + Task { + do { + let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest() weeklyDigest = digest - showDigest = true + } catch { + // Not enough data or AI unavailable — just don't show the card } } } @@ -118,10 +132,8 @@ struct InsightsView: View { ScrollView { VStack(spacing: 20) { // Weekly Digest Card - if showDigest, let digest = weeklyDigest { - WeeklyDigestCardView(digest: digest) { - showDigest = false - } + if let digest = weeklyDigest { + WeeklyDigestCardView(digest: digest) } // This Month Section @@ -166,6 +178,8 @@ struct InsightsView: View { .padding(.vertical) .padding(.bottom, 100) } + .opacity(isGeneratingInsights && !iapManager.shouldShowPaywall ? 0.2 : 1.0) + .animation(.easeInOut(duration: 0.3), value: isGeneratingInsights) .refreshable { viewModel.refreshInsights() // Small delay to show refresh animation @@ -174,6 +188,50 @@ struct InsightsView: View { .disabled(iapManager.shouldShowPaywall) } + // MARK: - Generating State + + private var isGeneratingInsights: Bool { + let states = [viewModel.monthLoadingState, viewModel.yearLoadingState, viewModel.allTimeLoadingState] + return states.contains(where: { $0 == .loading || $0 == .idle }) + } + + private var generatingOverlay: some View { + VStack(spacing: 20) { + Spacer() + + VStack(spacing: 16) { + Image(systemName: "sparkles") + .font(.system(size: 36)) + .foregroundStyle( + LinearGradient( + colors: [.purple, .blue], + startPoint: .leading, + endPoint: .trailing + ) + ) + .symbolEffect(.pulse, options: .repeating) + + Text(String(localized: "Generating Insights")) + .font(.headline) + .foregroundColor(textColor) + + Text(String(localized: "Apple Intelligence is analyzing your mood data...")) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(32) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.regularMaterial) + ) + .padding(.horizontal, 40) + + Spacer() + } + .transition(.opacity) + } + // MARK: - Paywall Overlay private var paywallOverlay: some View { diff --git a/Shared/Views/InsightsView/WeeklyDigestCardView.swift b/Shared/Views/InsightsView/WeeklyDigestCardView.swift index 7b1298c..d831156 100644 --- a/Shared/Views/InsightsView/WeeklyDigestCardView.swift +++ b/Shared/Views/InsightsView/WeeklyDigestCardView.swift @@ -10,7 +10,6 @@ import SwiftUI struct WeeklyDigestCardView: View { let digest: WeeklyDigest - let onDismiss: () -> Void @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @@ -18,82 +17,91 @@ struct WeeklyDigestCardView: View { private var textColor: Color { theme.currentTheme.labelColor } private var accentColor: Color { moodTint.color(forMood: .good) } + @State private var isExpanded = true @State private var appeared = false var body: some View { - VStack(alignment: .leading, spacing: 16) { - // Header - HStack { - Image(systemName: digest.iconName) - .font(.title2) - .foregroundStyle(accentColor) - - VStack(alignment: .leading, spacing: 2) { - Text(String(localized: "Weekly Digest")) - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - .textCase(.uppercase) - - Text(digest.headline) - .font(.headline) - .foregroundColor(textColor) + VStack(alignment: .leading, spacing: 0) { + // Header — always visible, tappable to toggle + Button { + withAnimation(.easeInOut(duration: 0.25)) { + isExpanded.toggle() } + } label: { + HStack { + Image(systemName: digest.iconName) + .font(.title2) + .foregroundStyle(accentColor) - Spacer() + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Weekly Digest")) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .textCase(.uppercase) - Button { - WeeklyDigest.markDismissed() - withAnimation(.easeInOut(duration: 0.3)) { - onDismiss() + Text(digest.headline) + .font(.headline) + .foregroundColor(textColor) + .multilineTextAlignment(.leading) } - } label: { - Image(systemName: "xmark.circle.fill") - .font(.title3) + + Spacer() + + Image(systemName: "chevron.up") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 0 : 180)) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier(AccessibilityID.WeeklyDigest.dismissButton) + + // Expandable content + if isExpanded { + VStack(alignment: .leading, spacing: 16) { + // Summary + Text(digest.summary) + .font(.subheadline) + .foregroundColor(textColor) + .fixedSize(horizontal: false, vertical: true) + + Divider() + + // Highlight + HStack(alignment: .top, spacing: 10) { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(.yellow) + .padding(.top, 2) + + Text(digest.highlight) + .font(.subheadline) + .foregroundColor(textColor) + .fixedSize(horizontal: false, vertical: true) + } + + // Intention + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.right.circle.fill") + .font(.caption) + .foregroundStyle(accentColor) + .padding(.top, 2) + + Text(digest.intention) + .font(.subheadline) + .foregroundColor(textColor) + .fixedSize(horizontal: false, vertical: true) + } + + // Date range + Text(dateRangeString) + .font(.caption2) .foregroundStyle(.tertiary) } - .accessibilityLabel(String(localized: "Dismiss digest")) - .accessibilityIdentifier(AccessibilityID.WeeklyDigest.dismissButton) + .padding(.top, 16) + .transition(.opacity.combined(with: .move(edge: .top))) } - - // Summary - Text(digest.summary) - .font(.subheadline) - .foregroundColor(textColor) - .fixedSize(horizontal: false, vertical: true) - - Divider() - - // Highlight - HStack(alignment: .top, spacing: 10) { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(.yellow) - .padding(.top, 2) - - Text(digest.highlight) - .font(.subheadline) - .foregroundColor(textColor) - .fixedSize(horizontal: false, vertical: true) - } - - // Intention - HStack(alignment: .top, spacing: 10) { - Image(systemName: "arrow.right.circle.fill") - .font(.caption) - .foregroundStyle(accentColor) - .padding(.top, 2) - - Text(digest.intention) - .font(.subheadline) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - // Date range - Text(dateRangeString) - .font(.caption2) - .foregroundStyle(.tertiary) } .padding(20) .background( diff --git a/Shared/Views/SettingsView/PaywallPreviewSettingsView.swift b/Shared/Views/SettingsView/PaywallPreviewSettingsView.swift index 774d6f3..ce4969a 100644 --- a/Shared/Views/SettingsView/PaywallPreviewSettingsView.swift +++ b/Shared/Views/SettingsView/PaywallPreviewSettingsView.swift @@ -7,7 +7,6 @@ import SwiftUI -#if DEBUG struct PaywallPreviewSettingsView: View { @Environment(\.dismiss) private var dismiss @State private var selectedStyle: PaywallStyle = .celestial @@ -928,4 +927,3 @@ struct JournalMiniPreview: View { .environmentObject(IAPManager()) } } -#endif diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index 0c54640..291b2e1 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -68,7 +68,6 @@ struct SettingsContentView: View { privacyButton analyticsToggle - #if DEBUG // Debug section debugSectionHeader addTestDataButton @@ -83,9 +82,9 @@ struct SettingsContentView: View { exportInsightsButton generateAndExportButton deleteHealthKitDataButton + generateWeeklyDigestButton clearDataButton - #endif Spacer() .frame(height: 20) @@ -207,7 +206,6 @@ struct SettingsContentView: View { // MARK: - Debug Section - #if DEBUG private var debugSectionHeader: some View { HStack { Text("Debug") @@ -759,6 +757,63 @@ struct SettingsContentView: View { .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } + @State private var isGeneratingDigest = false + @State private var digestResult: String? + + private var generateWeeklyDigestButton: some View { + Button { + isGeneratingDigest = true + digestResult = nil + Task { + if #available(iOS 26, *) { + do { + let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest() + digestResult = "✓ \(digest.headline)" + } catch { + digestResult = "✗ \(error.localizedDescription)" + } + } else { + digestResult = "✗ Requires iOS 26+" + } + isGeneratingDigest = false + } + } label: { + HStack(spacing: 12) { + if isGeneratingDigest { + ProgressView() + .frame(width: 32) + } else { + Image(systemName: "sparkles.rectangle.stack") + .font(.title2) + .foregroundColor(.purple) + .frame(width: 32) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Generate Weekly Digest") + .foregroundColor(textColor) + + if let result = digestResult { + Text(result) + .font(.caption) + .foregroundColor(result.contains("✓") ? .green : .red) + } else { + Text("Create AI digest now (shows in Insights tab)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + .padding() + } + .disabled(isGeneratingDigest) + .background(theme.currentTheme.secondaryBGColor) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + private var clearDataButton: some View { Button { MoodLogger.shared.deleteAllData() @@ -787,7 +842,6 @@ struct SettingsContentView: View { .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } - #endif // MARK: - Privacy Lock Toggle @@ -1394,7 +1448,6 @@ struct SettingsView: View { // specialThanksCell } -#if DEBUG Group { Divider() Text("Test builds only") @@ -1410,7 +1463,6 @@ struct SettingsView: View { Divider() } Spacer() -#endif Text("\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))") .font(.body) } diff --git a/Shared/Views/TipModalView.swift b/Shared/Views/TipModalView.swift index 5a2c7c5..e106111 100644 --- a/Shared/Views/TipModalView.swift +++ b/Shared/Views/TipModalView.swift @@ -246,7 +246,6 @@ extension View { // MARK: - Tips Preview View (Debug) -#if DEBUG struct TipsPreviewView: View { @Environment(\.dismiss) private var dismiss @State private var selectedTipIndex: Int? @@ -384,4 +383,3 @@ private struct TipIndexWrapper: Identifiable { TipsPreviewView() } } -#endif