Files
Reflect/Shared/Views/SettingsView/SettingsView.swift
Trey t 78d09803c3 Fix location/weather error handling and complete localization
Add authorization pre-check and 15s timeout to LocationManager to
prevent hanging continuations. WeatherManager now skips retry queue
when location permission is denied. Settings weather toggle shows
alert directing users to Settings when location is denied. Fill
remaining 32 untranslated strings to reach 100% localization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:37:28 -05:00

2303 lines
89 KiB
Swift

//
// SettingsView.swift
// Reflect
//
// Created by Trey Tartt on 1/8/22.
//
import SwiftUI
import UniformTypeIdentifiers
import StoreKit
// 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()
})
.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))
}
.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)
.padding()
.navigationTitle("Set Trial Start Date")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
showTrialDatePicker = false
// Refresh subscription state
Task {
await iapManager.checkSubscriptionStatus()
}
}
}
}
}
.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()
}
.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()
}
.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()
}
.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 {
print("📸 Widgets exported to: \(path.path)")
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)
.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 {
print("📸 Voting layouts exported to: \(path.path)")
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)
.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 {
print("⌚ Watch views exported to: \(path.path)")
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)
.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 {
print("✨ Insights exported to: \(path.path)")
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)
.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 {
print("📸 Sharing screenshots exported to: \(path.path)")
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)
.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)
.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()
.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()
}
.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 {
print("HealthKit authorization failed: \(error)")
AnalyticsManager.shared.track(.healthKitEnableFailed)
}
}
} else {
healthService.isEnabled = false
AnalyticsManager.shared.track(.healthKitDisabled)
}
}
))
.labelsHidden()
.disabled(iapManager.shouldShowPaywall)
.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(1))
let status = LocationManager.shared.authorizationStatus
if status == .denied || status == .restricted {
weatherEnabled = false
showLocationDeniedAlert = true
}
}
}
AnalyticsManager.shared.track(.weatherToggled(enabled: newValue))
}
))
.labelsHidden()
.disabled(iapManager.shouldShowPaywall)
.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)
}
}
Button(String(localized: "Cancel"), role: .cancel) {}
} 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()
})
.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))
}
.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)
.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()
Spacer()
}
.padding()
.navigationTitle("Reminder Time")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveReminderTime()
dismiss()
}
.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)
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))
print("Saved to \(url)")
case .failure(let error):
print(error.localizedDescription)
}
})
.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))
print("Unable to read file contents")
print(error.localizedDescription)
}
}
}
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()
}
.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 {
print("HealthKit authorization failed: \(error)")
AnalyticsManager.shared.track(.healthKitEnableFailed)
}
}
} else {
healthService.isEnabled = false
AnalyticsManager.shared.track(.healthKitDisabled)
}
}
))
.labelsHidden()
.disabled(iapManager.shouldShowPaywall)
} 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(1))
let status = LocationManager.shared.authorizationStatus
if status == .denied || status == .restricted {
weatherEnabled = false
showLocationDeniedAlert = true
}
}
}
AnalyticsManager.shared.track(.weatherToggled(enabled: newValue))
}
))
.labelsHidden()
.disabled(iapManager.shouldShowPaywall)
}
.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)
}
}
Button(String(localized: "Cancel"), role: .cancel) {}
} 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()
})
.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))
})
}
}
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)
})
.padding()
if showSpecialThanks {
Divider()
Link("Font Awesome", destination: URL(string: "https://fontawesome.com")!)
.accentColor(textColor)
.padding(.bottom)
Divider()
Link("Charts", destination: URL(string: "https://github.com/danielgindi/Charts")!)
.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)
})
.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))
}
.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)
.padding()
.navigationTitle("Set Trial Start Date")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
showTrialDatePicker = false
// Refresh subscription state
Task {
await iapManager.checkSubscriptionStatus()
}
}
}
}
}
.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)
})
.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)
})
.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)
})
.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)
})
.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)
})
.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)
})
.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)
})
.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))
}
.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)
.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)
})
.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)
})
.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)
print(url)
} catch {
print(error.localizedDescription)
}
}
}, label: {
Text("Create random icons")
.foregroundColor(textColor)
})
.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)
}
}