Audit found ~50+ interactive elements (buttons, toggles, pickers, alerts, links) missing accessibility identifiers across 13 view files. Added centralized ID definitions and applied them to every entry detail button, guided reflection control, settings toggle, paywall unlock button, subscription/IAP button, lock screen control, and photo action dialog.
2319 lines
90 KiB
Swift
2319 lines
90 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()
|
|
.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()
|
|
}
|
|
.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)
|
|
.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(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)
|
|
.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)
|
|
}
|
|
}
|
|
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))
|
|
}
|
|
.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)
|
|
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()
|
|
.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 {
|
|
print("HealthKit authorization failed: \(error)")
|
|
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(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)
|
|
.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)
|
|
}
|
|
}
|
|
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)
|
|
})
|
|
.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)
|
|
})
|
|
.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)
|
|
}
|
|
}
|