// // SettingsView.swift // Reflect // // Created by Trey Tartt on 1/8/22. // import SwiftUI import UniformTypeIdentifiers import StoreKit private enum SettingsAnimationConstants { static let locationPermissionCheckDelay: TimeInterval = 1.0 } // MARK: - Settings Content View (for use in SettingsTabView) struct SettingsContentView: View { @EnvironmentObject var authManager: BiometricAuthManager @EnvironmentObject var iapManager: IAPManager @State private var showOnboarding = false @State private var showExportView = false @State private var showReminderTimePicker = false @State private var showSubscriptionStore = false @State private var showTrialDatePicker = false @State private var isExportingWidgets = false @State private var widgetExportPath: URL? @State private var isExportingVotingLayouts = false @State private var votingLayoutExportPath: URL? @State private var isExportingWatchViews = false @State private var watchExportPath: URL? @State private var isExportingInsights = false @State private var insightsExportPath: URL? @State private var isGeneratingScreenshots = false @State private var sharingExportPath: URL? @State private var isDeletingHealthKitData = false @State private var healthKitDeleteResult: String? @StateObject private var healthService = HealthService.shared @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true @AppStorage(UserDefaultsStore.Keys.weatherEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var weatherEnabled = true @State private var showWeatherSubscriptionStore = false @State private var showLocationDeniedAlert = false private var textColor: Color { theme.currentTheme.labelColor } var body: some View { ScrollView { VStack { // Features section featuresSectionHeader privacyLockToggle healthKitToggle weatherToggle exportDataButton // Settings section settingsSectionHeader reminderTimeButton hapticFeedbackToggle canDelete showOnboardingButton // Legal section legalSectionHeader eulaButton privacyButton analyticsToggle #if DEBUG // Debug section debugSectionHeader addTestDataButton bypassSubscriptionToggle trialDateButton paywallPreviewButton tipsPreviewButton testNotificationsButton exportWidgetsButton exportVotingLayoutsButton exportWatchViewsButton exportInsightsButton generateAndExportButton deleteHealthKitDataButton clearDataButton #endif Spacer() .frame(height: 20) Text("\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))") .font(.body) .foregroundColor(.secondary) } .padding() } .sheet(isPresented: $showOnboarding) { OnboardingMain(onboardingData: UserDefaultsStore.getOnboarding(), updateBoardingDataClosure: { onboardingData in OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData) showOnboarding = false }) } .sheet(isPresented: $showExportView) { ExportView(entries: DataController.shared.getData( startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [] )) } .sheet(isPresented: $showReminderTimePicker) { ReminderTimePickerView() } .onAppear(perform: { AnalyticsManager.shared.trackScreen(.settings) ReflectTipsManager.shared.onSettingsViewed() }) } // MARK: - Reminder Time Button private var reminderTimeButton: some View { Button(action: { AnalyticsManager.shared.track(.reminderTimeTapped) showReminderTimePicker = true }, label: { HStack(spacing: 12) { Image(systemName: "clock.fill") .font(.title2) .foregroundColor(.orange) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Reminder Time") .foregroundColor(textColor) Text(formattedReminderTime) .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding() }) .accessibilityIdentifier(AccessibilityID.Settings.reminderTimeButton) .accessibilityLabel(String(localized: "Reminder Time")) .accessibilityValue(formattedReminderTime) .accessibilityHint(String(localized: "Opens time picker to change reminder time")) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.timeStyle = .short return formatter }() private var formattedReminderTime: String { let onboardingData = UserDefaultsStore.getOnboarding() return Self.timeFormatter.string(from: onboardingData.date) } // MARK: - Section Headers private var featuresSectionHeader: some View { HStack { Text("Features") .font(.headline) .foregroundColor(textColor) Spacer() } .padding(.top, 20) .padding(.horizontal, 4) .siriShortcutTip() } private var settingsSectionHeader: some View { HStack { Text("Settings") .font(.headline) .foregroundColor(textColor) Spacer() } .padding(.top, 20) .padding(.horizontal, 4) } private var legalSectionHeader: some View { HStack { Text("Legal") .font(.headline) .foregroundColor(textColor) Spacer() } .padding(.top, 20) .padding(.horizontal, 4) } // MARK: - Debug Section #if DEBUG private var debugSectionHeader: some View { HStack { Text("Debug") .font(.headline) .foregroundColor(.red) Spacer() } .padding(.top, 20) .padding(.horizontal, 4) } private var bypassSubscriptionToggle: some View { HStack(spacing: 12) { Image(systemName: "lock.open.fill") .font(.title2) .foregroundColor(.green) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Bypass Subscription") .foregroundColor(textColor) Text("Hide trial banner & grant full access") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: $iapManager.bypassSubscription) .labelsHidden() .accessibilityIdentifier(AccessibilityID.Settings.bypassSubscriptionToggle) } .padding() .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var trialDateButton: some View { VStack(spacing: 12) { HStack(spacing: 12) { Image(systemName: "calendar.badge.clock") .font(.title2) .foregroundColor(.orange) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Trial Start Date") .foregroundColor(textColor) Text("Current: \(firstLaunchDate.formatted(date: .abbreviated, time: .omitted))") .font(.caption) .foregroundStyle(.secondary) } Spacer() Button("Change") { showTrialDatePicker = true } .font(.subheadline.weight(.medium)) .accessibilityIdentifier(AccessibilityID.Settings.changeTrialDateButton) } .padding() } .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .sheet(isPresented: $showTrialDatePicker) { NavigationStack { DatePicker( "Trial Start Date", selection: $firstLaunchDate, displayedComponents: .date ) .datePickerStyle(.graphical) .accessibilityIdentifier(AccessibilityID.Settings.trialDatePicker) .padding() .navigationTitle("Set Trial Start Date") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showTrialDatePicker = false // Refresh subscription state Task { await iapManager.checkSubscriptionStatus() } } .accessibilityIdentifier(AccessibilityID.Settings.trialDatePickerDoneButton) } } } .presentationDetents([.medium]) } } @State private var showPaywallPreview = false @State private var showTipsPreview = false private var paywallPreviewButton: some View { Button { showPaywallPreview = true } label: { HStack(spacing: 12) { Image(systemName: "paintpalette.fill") .font(.title2) .foregroundStyle( LinearGradient( colors: [.purple, .pink, .orange], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Paywall Styles") .foregroundColor(textColor) Text("Preview subscription themes") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding() } .accessibilityIdentifier(AccessibilityID.Settings.paywallPreviewButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .sheet(isPresented: $showPaywallPreview) { NavigationStack { PaywallPreviewSettingsView() } } } private var tipsPreviewButton: some View { Button { showTipsPreview = true } label: { HStack(spacing: 12) { Image(systemName: "lightbulb.fill") .font(.title2) .foregroundStyle( LinearGradient( colors: [.yellow, .orange], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Tips Preview") .foregroundColor(textColor) Text("View all tip modals") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding() } .accessibilityIdentifier(AccessibilityID.Settings.tipsPreviewButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .sheet(isPresented: $showTipsPreview) { NavigationStack { TipsPreviewView() } } } private var testNotificationsButton: some View { Button { LocalNotification.sendAllPersonalityNotificationsForScreenshot() } label: { HStack(spacing: 12) { Image(systemName: "bell.badge.fill") .font(.title2) .foregroundColor(.red) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Test All Notifications") .foregroundColor(textColor) Text("Send 5 personality pack notifications") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "arrow.up.right") .font(.caption) .foregroundStyle(.tertiary) } .padding() } .accessibilityIdentifier(AccessibilityID.Settings.testNotificationsButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var exportWidgetsButton: some View { Button { isExportingWidgets = true Task { widgetExportPath = await WidgetExporter.exportAllWidgets() isExportingWidgets = false if let path = widgetExportPath { #if DEBUG print("📸 Widgets exported to: \(path.path)") #endif openInFilesApp(path) } } } label: { HStack(spacing: 12) { if isExportingWidgets { ProgressView() .frame(width: 32) } else { Image(systemName: "square.grid.2x2.fill") .font(.title2) .foregroundColor(.purple) .frame(width: 32) } VStack(alignment: .leading, spacing: 2) { Text("Export Widget Screenshots") .foregroundColor(textColor) if let path = widgetExportPath { Text("Saved to Documents/WidgetExports") .font(.caption) .foregroundColor(.green) } else { Text("Light & dark mode PNGs") .font(.caption) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "arrow.down.doc.fill") .font(.caption) .foregroundStyle(.tertiary) } .padding() } .disabled(isExportingWidgets) .accessibilityIdentifier(AccessibilityID.Settings.exportWidgetsButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var exportVotingLayoutsButton: some View { Button { isExportingVotingLayouts = true Task { votingLayoutExportPath = await WidgetExporter.exportAllVotingLayouts() isExportingVotingLayouts = false if let path = votingLayoutExportPath { #if DEBUG print("📸 Voting layouts exported to: \(path.path)") #endif openInFilesApp(path) } } } label: { HStack(spacing: 12) { if isExportingVotingLayouts { ProgressView() .frame(width: 32) } else { Image(systemName: "hand.tap.fill") .font(.title2) .foregroundColor(.blue) .frame(width: 32) } VStack(alignment: .leading, spacing: 2) { Text("Export Voting Layouts") .foregroundColor(textColor) if let path = votingLayoutExportPath { Text("Saved to Documents/VotingLayoutExports") .font(.caption) .foregroundColor(.green) } else { Text("All sizes & theme variations") .font(.caption) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "arrow.down.doc.fill") .font(.caption) .foregroundStyle(.tertiary) } .padding() } .disabled(isExportingVotingLayouts) .accessibilityIdentifier(AccessibilityID.Settings.exportVotingLayoutsButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var exportWatchViewsButton: some View { Button { isExportingWatchViews = true Task { watchExportPath = await WatchExporter.exportAllWatchViews() isExportingWatchViews = false if let path = watchExportPath { #if DEBUG print("⌚ Watch views exported to: \(path.path)") #endif openInFilesApp(path) } } } label: { HStack(spacing: 12) { if isExportingWatchViews { ProgressView() .frame(width: 32) } else { Image(systemName: "applewatch.watchface") .font(.title2) .foregroundColor(.cyan) .frame(width: 32) } VStack(alignment: .leading, spacing: 2) { Text("Export Watch Screenshots") .foregroundColor(textColor) if let path = watchExportPath { Text("Saved to Documents/WatchExports") .font(.caption) .foregroundColor(.green) } else { Text("All styles & complications") .font(.caption) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "arrow.down.doc.fill") .font(.caption) .foregroundStyle(.tertiary) } .padding() } .disabled(isExportingWatchViews) .accessibilityIdentifier(AccessibilityID.Settings.exportWatchViewsButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var exportInsightsButton: some View { Button { isExportingInsights = true Task { insightsExportPath = await InsightsExporter.exportInsightsScreenshots() isExportingInsights = false if let path = insightsExportPath { #if DEBUG print("✨ Insights exported to: \(path.path)") #endif openInFilesApp(path) } } } label: { HStack(spacing: 12) { if isExportingInsights { ProgressView() .frame(width: 32) } else { Image(systemName: "sparkles") .font(.title2) .foregroundStyle( LinearGradient( colors: [.purple, .blue], startPoint: .leading, endPoint: .trailing ) ) .frame(width: 32) } VStack(alignment: .leading, spacing: 2) { Text("Export Insights Screenshots") .foregroundColor(textColor) if let path = insightsExportPath { Text("Saved to Documents/InsightsExports") .font(.caption) .foregroundColor(.green) } else { Text("AI insights in light & dark mode") .font(.caption) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "arrow.down.doc.fill") .font(.caption) .foregroundStyle(.tertiary) } .padding() } .disabled(isExportingInsights) .accessibilityIdentifier(AccessibilityID.Settings.exportInsightsButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var generateAndExportButton: some View { Button { isGeneratingScreenshots = true Task { DataController.shared.populate2YearsData() sharingExportPath = await SharingScreenshotExporter.exportAllSharingScreenshots() isGeneratingScreenshots = false if let path = sharingExportPath { #if DEBUG print("📸 Sharing screenshots exported to: \(path.path)") #endif openInFilesApp(path) } } } label: { HStack(spacing: 12) { if isGeneratingScreenshots { ProgressView() .frame(width: 32) } else { Image(systemName: "photo.on.rectangle.angled") .font(.title2) .foregroundStyle( LinearGradient( colors: [.green, .blue], startPoint: .leading, endPoint: .trailing ) ) .frame(width: 32) } VStack(alignment: .leading, spacing: 2) { Text("Generate & Export Sharing") .foregroundColor(textColor) if let path = sharingExportPath { Text("Saved to Documents/SharingExports") .font(.caption) .foregroundColor(.green) } else { Text("Fill 2 years data + export PNGs") .font(.caption) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "arrow.down.doc.fill") .font(.caption) .foregroundStyle(.tertiary) } .padding() } .disabled(isGeneratingScreenshots) .accessibilityIdentifier(AccessibilityID.Settings.generateScreenshotsButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var deleteHealthKitDataButton: some View { Button { isDeletingHealthKitData = true healthKitDeleteResult = nil Task { do { let count = try await HealthKitManager.shared.deleteAllMoods() healthKitDeleteResult = "✓ Deleted \(count) records" } catch { healthKitDeleteResult = "✗ Error: \(error.localizedDescription)" } isDeletingHealthKitData = false } } label: { HStack(spacing: 12) { if isDeletingHealthKitData { ProgressView() .frame(width: 32) } else { Image(systemName: "heart.slash.fill") .font(.title2) .foregroundColor(.red) .frame(width: 32) } VStack(alignment: .leading, spacing: 2) { Text("Delete HealthKit Data") .foregroundColor(textColor) if let result = healthKitDeleteResult { Text(result) .font(.caption) .foregroundColor(result.contains("✓") ? .green : .red) } else { Text("Remove all State of Mind records") .font(.caption) .foregroundStyle(.secondary) } } Spacer() } .padding() } .disabled(isDeletingHealthKitData) .accessibilityIdentifier(AccessibilityID.Settings.deleteHealthKitButton) .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() } label: { HStack(spacing: 12) { Image(systemName: "trash") .font(.title2) .foregroundColor(.red) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Clear All Data") .foregroundColor(textColor) Text("Delete all mood entries") .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding() } .background(theme.currentTheme.secondaryBGColor) .accessibilityIdentifier(AccessibilityID.Settings.clearDataButton) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } #endif // MARK: - Privacy Lock Toggle @ViewBuilder private var privacyLockToggle: some View { if authManager.canUseBiometrics { HStack(spacing: 12) { Image(systemName: authManager.biometricIcon) .font(.title2) .foregroundColor(.accentColor) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Privacy Lock") .foregroundColor(textColor) Text("Require \(authManager.biometricName) to open app") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: Binding( get: { authManager.isLockEnabled }, set: { newValue in Task { if newValue { let success = await authManager.enableLock() if !success { AnalyticsManager.shared.track(.privacyLockEnableFailed) } } else { authManager.disableLock() } } } )) .labelsHidden() .accessibilityIdentifier(AccessibilityID.Settings.privacyLockToggle) .accessibilityLabel(String(localized: "Privacy Lock")) .accessibilityHint(String(localized: "Require biometric authentication to open app")) } .padding() .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } } // MARK: - Health Kit Toggle @ObservedObject private var healthKitManager = HealthKitManager.shared private var addTestDataButton: some View { Button { DataController.shared.populateTestData() } label: { HStack(spacing: 12) { Image(systemName: "plus.square.on.square") .font(.title2) .foregroundColor(.green) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Add Test Data") .foregroundColor(textColor) Text("Populate with sample mood entries") .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding() } .accessibilityIdentifier(AccessibilityID.Settings.addTestDataButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var healthKitToggle: some View { VStack(spacing: 0) { HStack(spacing: 12) { Image(systemName: "heart.fill") .font(.title2) .foregroundColor(.red) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Apple Health") .foregroundColor(textColor) Text("Correlate mood with health data") .font(.caption) .foregroundStyle(.secondary) } Spacer() if healthService.isAvailable { // Disable toggle and force off when paywall should show Toggle("", isOn: Binding( get: { iapManager.shouldShowPaywall ? false : healthService.isEnabled }, set: { newValue in // If paywall should show, show subscription store instead if iapManager.shouldShowPaywall { showSubscriptionStore = true return } if newValue { Task { // Request all permissions in a single dialog do { let authorized = try await HealthKitManager.shared.requestAllPermissions() healthService.isEnabled = true healthService.isAuthorized = true if authorized { // Sync all existing moods to HealthKit await HealthKitManager.shared.syncAllMoods() } else { AnalyticsManager.shared.track(.healthKitNotAuthorized) } } catch { #if DEBUG print("HealthKit authorization failed: \(error)") #endif AnalyticsManager.shared.track(.healthKitEnableFailed) } } } else { healthService.isEnabled = false AnalyticsManager.shared.track(.healthKitDisabled) } } )) .labelsHidden() .disabled(iapManager.shouldShowPaywall) .accessibilityIdentifier(AccessibilityID.Settings.healthSyncToggle) .accessibilityLabel(String(localized: "Apple Health")) .accessibilityHint(String(localized: "Sync mood data with Apple Health")) } else { Text("Not Available") .font(.caption) .foregroundStyle(.secondary) .accessibilityLabel(String(localized: "Apple Health not available")) } } .padding() // Show premium badge when paywall should show if iapManager.shouldShowPaywall { HStack { Image(systemName: "crown.fill") .foregroundColor(.yellow) Text("Premium Feature") .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal) .padding(.bottom, 12) .accessibilityElement(children: .combine) .accessibilityLabel(String(localized: "Premium feature, subscription required")) } // Show sync progress or status else if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty { VStack(spacing: 4) { if healthKitManager.isSyncing { ProgressView(value: healthKitManager.syncProgress) .tint(.red) .accessibilityLabel(String(localized: "Syncing health data")) .accessibilityValue("\(Int(healthKitManager.syncProgress * 100)) percent") } Text(healthKitManager.syncStatus) .font(.caption) .foregroundStyle(healthKitManager.syncStatus.contains("✓") ? .green : .secondary) } .padding(.horizontal) .padding(.bottom, 12) } } .background(theme.currentTheme.secondaryBGColor) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .healthKitSyncTip() .sheet(isPresented: $showSubscriptionStore) { ReflectSubscriptionStoreView(source: "settings") } } // MARK: - Weather Toggle private var weatherToggle: some View { VStack(spacing: 0) { HStack(spacing: 12) { Image(systemName: "cloud.sun.fill") .font(.title2) .foregroundColor(.blue) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Weather") .foregroundColor(textColor) Text("Show weather details for each day") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: Binding( get: { iapManager.shouldShowPaywall ? false : weatherEnabled }, set: { newValue in if iapManager.shouldShowPaywall { showWeatherSubscriptionStore = true return } weatherEnabled = newValue if newValue { LocationManager.shared.requestAuthorization() // Check if permission was denied after a brief delay Task { try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay)) let status = LocationManager.shared.authorizationStatus if status == .denied || status == .restricted { weatherEnabled = false showLocationDeniedAlert = true } } } AnalyticsManager.shared.track(.weatherToggled(enabled: newValue)) } )) .labelsHidden() .disabled(iapManager.shouldShowPaywall) .accessibilityIdentifier(AccessibilityID.Settings.weatherToggle) .accessibilityLabel(String(localized: "Weather")) .accessibilityHint(String(localized: "Show weather details for each day")) } .padding() if iapManager.shouldShowPaywall { HStack { Image(systemName: "crown.fill") .foregroundColor(.yellow) Text("Premium Feature") .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal) .padding(.bottom, 12) .accessibilityElement(children: .combine) .accessibilityLabel(String(localized: "Premium feature, subscription required")) } } .background(theme.currentTheme.secondaryBGColor) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .sheet(isPresented: $showWeatherSubscriptionStore) { ReflectSubscriptionStoreView(source: "settings") } .alert( String(localized: "Location Access Required"), isPresented: $showLocationDeniedAlert ) { Button(String(localized: "Open Settings")) { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .accessibilityIdentifier(AccessibilityID.Settings.locationAlertOpenSettingsButton) Button(String(localized: "Cancel"), role: .cancel) {} .accessibilityIdentifier(AccessibilityID.Settings.locationAlertCancelButton) } message: { Text("Reflect needs location access to show weather. You can enable it in Settings.") } } // MARK: - Export Data Button private var exportDataButton: some View { Button(action: { AnalyticsManager.shared.track(.exportTapped) showExportView = true }, label: { HStack(spacing: 12) { Image(systemName: "square.and.arrow.up") .font(.title2) .foregroundColor(.accentColor) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Export Data") .foregroundColor(textColor) Text("CSV or PDF report") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding() }) .accessibilityIdentifier(AccessibilityID.Settings.exportDataButton) .accessibilityLabel(String(localized: "Export Data")) .accessibilityHint(String(localized: "Export your mood data as CSV or PDF")) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var showOnboardingButton: some View { Button(action: { AnalyticsManager.shared.track(.onboardingReshown) showOnboarding.toggle() }, label: { Text(String(localized: "settings_view_show_onboarding")) .foregroundColor(textColor) }) .accessibilityHint(String(localized: "View the app introduction again")) .accessibilityIdentifier(AccessibilityID.Settings.showOnboardingButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var hapticFeedbackToggle: some View { HStack(spacing: 12) { Image(systemName: "waveform") .font(.title2) .foregroundColor(.purple) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text(String(localized: "Haptic Feedback")) .foregroundColor(textColor) Text(String(localized: "Vibrate when logging mood")) .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: $hapticFeedbackEnabled) .labelsHidden() .onChange(of: hapticFeedbackEnabled) { _, newValue in AnalyticsManager.shared.track(.hapticFeedbackToggled(enabled: newValue)) } .accessibilityIdentifier(AccessibilityID.Settings.hapticFeedbackToggle) .accessibilityLabel(String(localized: "Haptic Feedback")) .accessibilityHint(String(localized: "Toggle vibration feedback when voting")) } .padding() .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var canDelete: some View { VStack { Toggle(String(localized: "settings_use_delete_enable"), isOn: $deleteEnabled) .onChange(of: deleteEnabled) { _, newValue in AnalyticsManager.shared.track(.deleteToggleChanged(enabled: newValue)) } .foregroundColor(textColor) .accessibilityIdentifier(AccessibilityID.Settings.deleteToggle) .accessibilityHint(String(localized: "Allow deleting mood entries by swiping")) .padding() } .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var eulaButton: some View { Button(action: { AnalyticsManager.shared.track(.eulaViewed) if let url = URL(string: "https://reflect.88oakapps.com/eula.html") { UIApplication.shared.open(url) } }, label: { Text(String(localized: "settings_view_show_eula")) .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.eulaButton) .accessibilityHint(String(localized: "Opens End User License Agreement in browser")) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var privacyButton: some View { Button(action: { AnalyticsManager.shared.track(.privacyPolicyViewed) if let url = URL(string: "https://reflect.88oakapps.com/privacy.html") { UIApplication.shared.open(url) } }, label: { Text(String(localized: "settings_view_show_privacy")) .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.privacyPolicyButton) .accessibilityHint(String(localized: "Opens Privacy Policy in browser")) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } // MARK: - Analytics Toggle private var analyticsToggle: some View { HStack(spacing: 12) { Image(systemName: "chart.bar.xaxis") .font(.title2) .foregroundColor(.accentColor) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Share Analytics") .foregroundColor(textColor) Text("Help improve Reflect by sharing anonymous usage data") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: Binding( get: { !AnalyticsManager.shared.isOptedOut }, set: { enabled in if enabled { AnalyticsManager.shared.optIn() } else { AnalyticsManager.shared.optOut() } } )) .labelsHidden() .accessibilityIdentifier(AccessibilityID.Settings.analyticsToggle) .accessibilityLabel("Share Analytics") .accessibilityHint("Toggle anonymous usage analytics") } .padding() .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } // MARK: - Helper Functions private func openInFilesApp(_ url: URL) { #if targetEnvironment(simulator) // On simulator, copy path to clipboard for easy Finder access (Cmd+Shift+G) UIPasteboard.general.string = url.path #else // On device, open Files app let filesAppURL = URL(string: "shareddocuments://\(url.path)")! if UIApplication.shared.canOpenURL(filesAppURL) { UIApplication.shared.open(filesAppURL) } #endif } } // MARK: - Reminder Time Picker View struct ReminderTimePickerView: View { @Environment(\.dismiss) private var dismiss @State private var selectedTime: Date init() { let onboardingData = UserDefaultsStore.getOnboarding() _selectedTime = State(initialValue: onboardingData.date) } var body: some View { NavigationStack { VStack(spacing: 24) { Text("When would you like to be reminded to log your mood?") .font(.headline) .multilineTextAlignment(.center) .padding(.top, 20) DatePicker( "Reminder Time", selection: $selectedTime, displayedComponents: .hourAndMinute ) .datePickerStyle(.wheel) .labelsHidden() .accessibilityIdentifier(AccessibilityID.Settings.reminderTimePicker) Spacer() } .padding() .navigationTitle("Reminder Time") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } .accessibilityIdentifier(AccessibilityID.Settings.reminderCancelButton) } ToolbarItem(placement: .confirmationAction) { Button("Save") { saveReminderTime() dismiss() } .accessibilityIdentifier(AccessibilityID.Settings.reminderSaveButton) .fontWeight(.semibold) } } } } private func saveReminderTime() { let onboardingData = UserDefaultsStore.getOnboarding() onboardingData.date = selectedTime // This handles notification scheduling and Live Activity rescheduling OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData) AnalyticsManager.shared.track(.reminderTimeUpdated) } } // MARK: - Legacy SettingsView (sheet presentation with close button) struct SettingsView: View { @Environment(\.dismiss) var dismiss @Environment(\.openURL) var openURL @EnvironmentObject var iapManager: IAPManager @EnvironmentObject var authManager: BiometricAuthManager @State private var showingExporter = false @State private var showingImporter = false @State private var importContent = "" @State private var showOnboarding = false @State private var showExportView = false @State private var showSpecialThanks = false @State private var showWhyBGMode = false @State private var showSubscriptionStore = false @State private var showTrialDatePicker = false @StateObject private var healthService = HealthService.shared @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() @AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false @AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true @AppStorage(UserDefaultsStore.Keys.weatherEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var weatherEnabled = true @State private var showWeatherSubscriptionStore = false @State private var showLocationDeniedAlert = false private var textColor: Color { theme.currentTheme.labelColor } var body: some View { ScrollView { VStack { Group { closeButtonView .padding() // cloudKitEnable subscriptionInfoView // Features section featuresSectionHeader privacyLockToggle healthKitToggle weatherToggle exportDataButton // Settings section settingsSectionHeader hapticFeedbackToggle canDelete showOnboardingButton // Legal section legalSectionHeader eulaButton privacyButton analyticsToggle // specialThanksCell } #if DEBUG Group { Divider() Text("Test builds only") Toggle("Bypass Subscription", isOn: $iapManager.bypassSubscription) .accessibilityIdentifier(AccessibilityID.Settings.bypassSubscriptionToggle) addTestDataCell clearDB // fixWeekday exportData importData editFirstLaunchDatePast resetLaunchDate Divider() } Spacer() #endif Text("\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))") .font(.body) } .padding() }.sheet(isPresented: $showOnboarding) { OnboardingMain(onboardingData: UserDefaultsStore.getOnboarding(), updateBoardingDataClosure: { onboardingData in OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData) showOnboarding = false }) } .sheet(isPresented: $showExportView) { ExportView(entries: DataController.shared.getData( startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [] )) } .onAppear(perform: { AnalyticsManager.shared.trackScreen(.settings) }) .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) .fileExporter(isPresented: $showingExporter, documents: [ TextFile() ], contentType: .plainText, onCompletion: { result in switch result { case .success(let url): AnalyticsManager.shared.track(.dataExported(format: "file", count: 0)) #if DEBUG print("Saved to \(url)") #endif case .failure(let error): #if DEBUG print(error.localizedDescription) #endif } }) .fileImporter(isPresented: $showingImporter, allowedContentTypes: [.text], allowsMultipleSelection: false) { result in do { guard let selectedFile: URL = try result.get().first else { return } if selectedFile.startAccessingSecurityScopedResource() { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" dateFormatter.timeZone = TimeZone(abbreviation: "UTC") guard let input = String(data: try Data(contentsOf: selectedFile), encoding: .utf8) else { return } defer { selectedFile.stopAccessingSecurityScopedResource() } var rows = input.components(separatedBy: "\n") guard !rows.isEmpty else { return } rows.removeFirst() var importEntries: [(mood: Mood, date: Date, entryType: EntryType)] = [] for row in rows { let stripped = row.replacingOccurrences(of: " +0000", with: "") let columns = stripped.components(separatedBy: ",") if columns.count != 7 { continue } guard let forDate = dateFormatter.date(from: columns[3]), let moodValue = Int(columns[4]) else { continue } let mood = Mood(rawValue: moodValue) ?? .missing let entryType = EntryType(rawValue: Int(columns[2]) ?? 0) ?? .listView importEntries.append((mood: mood, date: forDate, entryType: entryType)) } MoodLogger.shared.importMoods(importEntries) AnalyticsManager.shared.track(.importSucceeded) } else { AnalyticsManager.shared.track(.importFailed(error: nil)) } } catch { // Handle failure. AnalyticsManager.shared.track(.importFailed(error: error.localizedDescription)) #if DEBUG print("Unable to read file contents") print(error.localizedDescription) #endif } } } private var subscriptionInfoView: some View { PurchaseButtonView(iapManager: iapManager) } // MARK: - Section Headers private var featuresSectionHeader: some View { HStack { Text("Features") .font(.headline) .foregroundColor(textColor) Spacer() } .padding(.top, 20) .padding(.horizontal, 4) } private var settingsSectionHeader: some View { HStack { Text("Settings") .font(.headline) .foregroundColor(textColor) Spacer() } .padding(.top, 20) .padding(.horizontal, 4) } private var legalSectionHeader: some View { HStack { Text("Legal") .font(.headline) .foregroundColor(textColor) Spacer() } .padding(.top, 20) .padding(.horizontal, 4) } // MARK: - Privacy Lock Toggle @ViewBuilder private var privacyLockToggle: some View { if authManager.canUseBiometrics { HStack(spacing: 12) { Image(systemName: authManager.biometricIcon) .font(.title2) .foregroundColor(.accentColor) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Privacy Lock") .foregroundColor(textColor) Text("Require \(authManager.biometricName) to open app") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: Binding( get: { authManager.isLockEnabled }, set: { newValue in Task { if newValue { let success = await authManager.enableLock() if !success { AnalyticsManager.shared.track(.privacyLockEnableFailed) } } else { authManager.disableLock() } } } )) .labelsHidden() .accessibilityIdentifier(AccessibilityID.Settings.privacyLockToggle) } .padding() .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } } // MARK: - Health Kit Toggle @ObservedObject private var healthKitManager = HealthKitManager.shared private var healthKitToggle: some View { VStack(spacing: 0) { HStack(spacing: 12) { Image(systemName: "heart.fill") .font(.title2) .foregroundColor(.red) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Apple Health") .foregroundColor(textColor) Text("Correlate mood with health data") .font(.caption) .foregroundStyle(.secondary) } Spacer() if healthService.isAvailable { // Disable toggle and force off when paywall should show Toggle("", isOn: Binding( get: { iapManager.shouldShowPaywall ? false : healthService.isEnabled }, set: { newValue in // If paywall should show, show subscription store instead if iapManager.shouldShowPaywall { showSubscriptionStore = true return } if newValue { Task { // Request all permissions in a single dialog do { let authorized = try await HealthKitManager.shared.requestAllPermissions() healthService.isEnabled = true healthService.isAuthorized = true if authorized { // Sync all existing moods to HealthKit await HealthKitManager.shared.syncAllMoods() } else { AnalyticsManager.shared.track(.healthKitNotAuthorized) } } catch { #if DEBUG print("HealthKit authorization failed: \(error)") #endif AnalyticsManager.shared.track(.healthKitEnableFailed) } } } else { healthService.isEnabled = false AnalyticsManager.shared.track(.healthKitDisabled) } } )) .labelsHidden() .disabled(iapManager.shouldShowPaywall) .accessibilityIdentifier(AccessibilityID.Settings.healthSyncToggle) } else { Text("Not Available") .font(.caption) .foregroundStyle(.secondary) } } .padding() // Show premium badge when paywall should show if iapManager.shouldShowPaywall { HStack { Image(systemName: "crown.fill") .foregroundColor(.yellow) Text("Premium Feature") .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal) .padding(.bottom, 12) } // Show sync progress or status else if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty { VStack(spacing: 4) { if healthKitManager.isSyncing { ProgressView(value: healthKitManager.syncProgress) .tint(.red) } Text(healthKitManager.syncStatus) .font(.caption) .foregroundStyle(healthKitManager.syncStatus.contains("✓") ? .green : .secondary) } .padding(.horizontal) .padding(.bottom, 12) } } .background(theme.currentTheme.secondaryBGColor) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .sheet(isPresented: $showSubscriptionStore) { ReflectSubscriptionStoreView(source: "settings") } } // MARK: - Weather Toggle private var weatherToggle: some View { VStack(spacing: 0) { HStack(spacing: 12) { Image(systemName: "cloud.sun.fill") .font(.title2) .foregroundColor(.blue) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Weather") .foregroundColor(textColor) Text("Show weather details for each day") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: Binding( get: { iapManager.shouldShowPaywall ? false : weatherEnabled }, set: { newValue in if iapManager.shouldShowPaywall { showWeatherSubscriptionStore = true return } weatherEnabled = newValue if newValue { LocationManager.shared.requestAuthorization() // Check if permission was denied after a brief delay Task { try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay)) let status = LocationManager.shared.authorizationStatus if status == .denied || status == .restricted { weatherEnabled = false showLocationDeniedAlert = true } } } AnalyticsManager.shared.track(.weatherToggled(enabled: newValue)) } )) .labelsHidden() .disabled(iapManager.shouldShowPaywall) .accessibilityIdentifier(AccessibilityID.Settings.weatherToggle) } .padding() if iapManager.shouldShowPaywall { HStack { Image(systemName: "crown.fill") .foregroundColor(.yellow) Text("Premium Feature") .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal) .padding(.bottom, 12) } } .background(theme.currentTheme.secondaryBGColor) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .sheet(isPresented: $showWeatherSubscriptionStore) { ReflectSubscriptionStoreView(source: "settings") } .alert( String(localized: "Location Access Required"), isPresented: $showLocationDeniedAlert ) { Button(String(localized: "Open Settings")) { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .accessibilityIdentifier(AccessibilityID.Settings.locationAlertOpenSettingsButton) Button(String(localized: "Cancel"), role: .cancel) {} .accessibilityIdentifier(AccessibilityID.Settings.locationAlertCancelButton) } message: { Text("Reflect needs location access to show weather. You can enable it in Settings.") } } // MARK: - Export Data Button private var exportDataButton: some View { Button(action: { AnalyticsManager.shared.track(.exportTapped) showExportView = true }, label: { HStack(spacing: 12) { Image(systemName: "square.and.arrow.up") .font(.title2) .foregroundColor(.accentColor) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Export Data") .foregroundColor(textColor) Text("CSV or PDF report") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding() }) .accessibilityIdentifier(AccessibilityID.Settings.exportDataButton) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var closeButtonView: some View { HStack{ Spacer() Button(action: { AnalyticsManager.shared.track(.settingsClosed) dismiss() }, label: { Text(String(localized: "settings_view_exit")) .font(.body) .foregroundColor(Color(UIColor.systemBlue)) }) .accessibilityIdentifier(AccessibilityID.Settings.closeButton) } } private var specialThanksCell: some View { VStack { Button(action: { AnalyticsManager.shared.track(.specialThanksViewed) withAnimation{ showSpecialThanks.toggle() } }, label: { Text(String(localized: "settings_view_special_thanks_to_title")) .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.specialThanksButton) .padding() if showSpecialThanks { Divider() Link("Font Awesome", destination: URL(string: "https://fontawesome.com")!) .accessibilityIdentifier(AccessibilityID.Settings.fontAwesomeLink) .accentColor(textColor) .padding(.bottom) Divider() Link("Charts", destination: URL(string: "https://github.com/danielgindi/Charts")!) .accessibilityIdentifier(AccessibilityID.Settings.chartsLink) .accentColor(textColor) .padding(.bottom) } } .background(theme.currentTheme.secondaryBGColor) .frame(minWidth: 0, maxWidth: .infinity) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var addTestDataCell: some View { Button(action: { DataController.shared.populateTestData() }, label: { Text("Add test data") .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.addTestDataButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var editFirstLaunchDatePast: some View { HStack(spacing: 12) { Image(systemName: "calendar.badge.clock") .font(.title2) .foregroundColor(.orange) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Trial Start Date") .foregroundColor(textColor) Text("Current: \(firstLaunchDate.formatted(date: .abbreviated, time: .omitted))") .font(.caption) .foregroundStyle(.secondary) } Spacer() Button("Change") { showTrialDatePicker = true } .font(.subheadline.weight(.medium)) .accessibilityIdentifier(AccessibilityID.Settings.changeTrialDateButton) } .padding() .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .sheet(isPresented: $showTrialDatePicker) { NavigationStack { DatePicker( "Trial Start Date", selection: $firstLaunchDate, displayedComponents: .date ) .datePickerStyle(.graphical) .accessibilityIdentifier(AccessibilityID.Settings.trialDatePicker) .padding() .navigationTitle("Set Trial Start Date") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showTrialDatePicker = false // Refresh subscription state Task { await iapManager.checkSubscriptionStatus() } } .accessibilityIdentifier(AccessibilityID.Settings.trialDatePickerDoneButton) } } } .presentationDetents([.medium]) } } private var resetLaunchDate: some View { Button(action: { firstLaunchDate = Date() Task { await iapManager.checkSubscriptionStatus() } }, label: { Text("Reset luanch date to current date") .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.resetLaunchDateButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var clearDB: some View { Button(action: { MoodLogger.shared.deleteAllData() }, label: { Text("Clear DB") .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.clearDataButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var fixWeekday: some View { Button(action: { DataController.shared.fixWrongWeekdays() }, label: { Text("Fix Weekday") .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.fixWeekdayButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var whyBackgroundMode: some View { VStack { Button(action: { withAnimation{ showWhyBGMode.toggle() } }, label: { Text(String(localized: "settings_view_why_bg_mode_title")) .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.whyBackgroundModeButton) .padding() if showWhyBGMode { Text(String(localized: "settings_view_why_bg_mode_body")) .foregroundColor(textColor) .padding() } } .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var showOnboardingButton: some View { Button(action: { AnalyticsManager.shared.track(.onboardingReshown) showOnboarding.toggle() }, label: { Text(String(localized: "settings_view_show_onboarding")) .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.showOnboardingButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var eulaButton: some View { Button(action: { AnalyticsManager.shared.track(.eulaViewed) if let url = URL(string: "https://reflect.88oakapps.com/eula.html") { openURL(url) } }, label: { Text(String(localized: "settings_view_show_eula")) .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.eulaButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var privacyButton: some View { Button(action: { AnalyticsManager.shared.track(.privacyPolicyViewed) if let url = URL(string: "https://reflect.88oakapps.com/privacy.html") { openURL(url) } }, label: { Text(String(localized: "settings_view_show_privacy")) .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.privacyPolicyButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var analyticsToggle: some View { HStack(spacing: 12) { Image(systemName: "chart.bar.xaxis") .font(.title2) .foregroundColor(.accentColor) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Share Analytics") .foregroundColor(textColor) Text("Help improve Reflect by sharing anonymous usage data") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: Binding( get: { !AnalyticsManager.shared.isOptedOut }, set: { enabled in if enabled { AnalyticsManager.shared.optIn() } else { AnalyticsManager.shared.optOut() } } )) .labelsHidden() .accessibilityIdentifier(AccessibilityID.Settings.analyticsToggle) .accessibilityLabel("Share Analytics") .accessibilityHint("Toggle anonymous usage analytics") } .padding() .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var hapticFeedbackToggle: some View { HStack(spacing: 12) { Image(systemName: "waveform") .font(.title2) .foregroundColor(.purple) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text(String(localized: "Haptic Feedback")) .foregroundColor(textColor) Text(String(localized: "Vibrate when logging mood")) .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: $hapticFeedbackEnabled) .labelsHidden() .onChange(of: hapticFeedbackEnabled) { _, newValue in AnalyticsManager.shared.track(.hapticFeedbackToggled(enabled: newValue)) } .accessibilityIdentifier(AccessibilityID.Settings.hapticFeedbackToggle) .accessibilityLabel(String(localized: "Haptic Feedback")) .accessibilityHint(String(localized: "Toggle vibration feedback when voting")) } .padding() .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var canDelete: some View { VStack { Toggle(String(localized: "settings_use_delete_enable"), isOn: $deleteEnabled) .onChange(of: deleteEnabled) { _, newValue in AnalyticsManager.shared.track(.deleteToggleChanged(enabled: newValue)) } .foregroundColor(textColor) .accessibilityIdentifier(AccessibilityID.Settings.deleteToggle) .padding() } .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var exportData: some View { Button(action: { showingExporter.toggle() AnalyticsManager.shared.track(.exportTapped) }, label: { Text("Export") .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.exportLegacyButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var importData: some View { Button(action: { showingImporter.toggle() AnalyticsManager.shared.track(.importTapped) }, label: { Text("Import") .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.importButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } private var randomIcons: some View { Button(action: { var iconViews = [UIImage]() // for _ in 0...300 { // iconViews.append( // IconView(iconViewModel: IconViewModel( // backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), // bgColor: Color.random(), // bgOverlayColor: Color.random(), // centerImage: MoodImages.FontAwesome.icon(forMood: .great), // innerColor: Color.random()) // ).asImage(size: CGSize(width: 1024, height: 1024))) // } iconViews.append( IconView(iconViewModel: IconViewModel( backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), bgColor: IconViewModel.great.bgColor, bgOverlayColor: IconViewModel.great.bgOverlayColor, centerImage: MoodImages.FontAwesome.icon(forMood: .great), innerColor: IconViewModel.great.innerColor) ).asImage(size: CGSize(width: 1024, height: 1024)) ) iconViews.append( IconView(iconViewModel: IconViewModel( backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), bgColor: IconViewModel.good.bgColor, bgOverlayColor: IconViewModel.good.bgOverlayColor, centerImage: MoodImages.FontAwesome.icon(forMood: .great), innerColor: IconViewModel.good.innerColor) ).asImage(size: CGSize(width: 1024, height: 1024)) ) iconViews.append( IconView(iconViewModel: IconViewModel( backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), bgColor: IconViewModel.average.bgColor, bgOverlayColor: IconViewModel.average.bgOverlayColor, centerImage: MoodImages.FontAwesome.icon(forMood: .great), innerColor: IconViewModel.average.innerColor) ).asImage(size: CGSize(width: 1024, height: 1024)) ) iconViews.append( IconView(iconViewModel: IconViewModel( backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), bgColor: IconViewModel.bad.bgColor, bgOverlayColor: IconViewModel.bad.bgOverlayColor, centerImage: MoodImages.FontAwesome.icon(forMood: .great), innerColor: IconViewModel.bad.innerColor) ).asImage(size: CGSize(width: 1024, height: 1024)) ) iconViews.append( IconView(iconViewModel: IconViewModel( backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), bgColor: IconViewModel.horrible.bgColor, bgOverlayColor: IconViewModel.horrible.bgOverlayColor, centerImage: MoodImages.FontAwesome.icon(forMood: .great), innerColor: IconViewModel.horrible.innerColor) ).asImage(size: CGSize(width: 1024, height: 1024)) ) // iconViews.append( // IconView(iconViewModel: IconViewModel( // backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), // bgColor: Color(hex: "EF0CF3"), // bgOverlayColor: Color(hex: "EF0CF3").darker(by: 40), // centerImage: MoodImages.FontAwesome.icon(forMood: .great), // innerColor: Color(hex: "EF0CF3")) // ).asImage(size: CGSize(width: 1024, height: 1024)) // ) // // iconViews.append( // IconView(iconViewModel: IconViewModel( // backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), // bgColor: Color(hex: "1AE5D6"), // bgOverlayColor: Color(hex: "1AE5D6").darker(by: 40), // centerImage: MoodImages.FontAwesome.icon(forMood: .great), // innerColor: Color(hex: "1AE5D6")) // ).asImage(size: CGSize(width: 1024, height: 1024)) // ) // // iconViews.append( // IconView(iconViewModel: IconViewModel( // backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), // bgColor: Color(hex: "633EC1"), // bgOverlayColor: Color(hex: "633EC1").darker(by: 40), // centerImage: MoodImages.FontAwesome.icon(forMood: .great), // innerColor: Color(hex: "633EC1")) // ).asImage(size: CGSize(width: 1024, height: 1024)) // ) // // iconViews.append( // IconView(iconViewModel: IconViewModel( // backgroundImage: MoodImages.FontAwesome.icon(forMood: .great), // bgColor: Color(hex: "10F30C"), // bgOverlayColor: Color(hex: "10F30C").darker(by: 40), // centerImage: MoodImages.FontAwesome.icon(forMood: .great), // innerColor: Color(hex: "10F30C")) // ).asImage(size: CGSize(width: 1024, height: 1024)) // ) for (idx, image) in iconViews.enumerated() { let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) var path = paths[0].appendingPathComponent("icons").path path = path.appending("\(idx).jpg") let url = URL(fileURLWithPath: path) do { try image.jpegData(compressionQuality: 1.0)?.write(to: url, options: .atomic) #if DEBUG print(url) #endif } catch { #if DEBUG print(error.localizedDescription) #endif } } }, label: { Text("Create random icons") .foregroundColor(textColor) }) .accessibilityIdentifier(AccessibilityID.Settings.randomIconsButton) .padding() .frame(maxWidth: .infinity) .background(theme.currentTheme.secondaryBGColor) .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } } struct TextFile: FileDocument { // tell the system we support only plain text static var readableContentTypes = [UTType.plainText] // by default our document is empty var text = "" // a simple initializer that creates new, empty documents @MainActor init() { let entries = DataController.shared.getData(startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: []) var csvString = "canDelete,canEdit,entryType,forDate,moodValue,timestamp,weekDay\n" for entry in entries { let canDelete = entry.canDelete let canEdit = entry.canEdit let entryType = entry.entryType let forDate = entry.forDate let moodValue = entry.moodValue let timestamp = entry.timestamp let weekDay = entry.weekDay let dataString = "\(canDelete),\(canEdit),\(entryType),\(String(describing: forDate)),\(moodValue),\(String(describing:timestamp)),\(weekDay)\n" csvString = csvString.appending(dataString) } text = csvString } // this initializer loads data that has been saved previously init(configuration: ReadConfiguration) throws { if let data = configuration.file.regularFileContents { text = String(decoding: data, as: UTF8.self) } } // this will be called when the system wants to write our data to disk func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { let data = Data(text.utf8) return FileWrapper(regularFileWithContents: data) } } struct SettingsView_Previews: PreviewProvider { static var previews: some View { SettingsView() SettingsView() .preferredColorScheme(.dark) } }